diff --git a/src/app/shared/components/template/components/audio/audio.component.ts b/src/app/shared/components/template/components/audio/audio.component.ts
index 68b91f4a00..8fe948fe2e 100644
--- a/src/app/shared/components/template/components/audio/audio.component.ts
+++ b/src/app/shared/components/template/components/audio/audio.component.ts
@@ -1,4 +1,4 @@
-import { Component, Input, OnDestroy, OnInit, ViewChild } from "@angular/core";
+import { Component, Input, OnDestroy, OnInit, signal, ViewChild } from "@angular/core";
import { FlowTypes } from "../../../../model";
import {
getBooleanParamFromTemplateRow,
@@ -66,7 +66,7 @@ export class TmplAudioComponent
/** @ignore */
errorTxt: string | null;
/** @ignore */
- progress = 0;
+ progress = signal(0);
/** @ignore */
rangeBarTouched: boolean = false;
/** @ignore */
@@ -178,7 +178,7 @@ export class TmplAudioComponent
return;
}
let seek: any = this.player.seek();
- this.progress = (seek / this.player.duration()) * 100 || 0;
+ this.progress.set((seek / this.player.duration()) * 100 || 0);
this.currentTimeSong = this.player.seek() ? (this.player.seek() as any).toString() : "0";
}, 1000);
}
@@ -199,7 +199,7 @@ export class TmplAudioComponent
customUpdateWhenRewind() {
if (!this.isPlayed) {
let seek: any = this.player.seek();
- this.progress = (seek / this.player.duration()) * 100 || 0;
+ this.progress.set((seek / this.player.duration()) * 100 || 0);
this.currentTimeSong = this.player.seek() ? (this.player.seek() as any).toString() : "0";
}
}
diff --git a/src/app/shared/components/template/components/index.ts b/src/app/shared/components/template/components/index.ts
index 66ca6a9f2e..bc90d760de 100644
--- a/src/app/shared/components/template/components/index.ts
+++ b/src/app/shared/components/template/components/index.ts
@@ -44,6 +44,7 @@ import { TmplOdkFormComponent } from "./odk-form/odk-form.component";
import { TmplParentPointBoxComponent } from "./points-item/points-item.component";
import { TmplParentPointCounterComponent } from "./parent-point-counter/parent-point-counter.component";
import { TmplPdfComponent } from "./pdf/pdf.component";
+import { TmplProgressPathComponent } from "./progress-path/progress-path.component";
import { TmplQRCodeComponent } from "./qr-code/qr-code.component";
import { TmplRadioButtonGridComponent } from "./radio-button-grid/radio-button-grid.component";
import { TmplRadioGroupComponent } from "./radio-group/radio-group.component";
@@ -62,6 +63,7 @@ import { TmplToggleBarComponent } from "./toggle-bar/toggle-bar";
import { TmplVideoComponent } from "./video";
import { WorkshopsComponent } from "./layout/workshops_accordion";
+import { TmplTextBubbleComponent } from "./text-bubble/text-bubble.component";
/** All components should be exported as a single array for easy module import */
export const TEMPLATE_COMPONENTS = [
@@ -103,6 +105,7 @@ export const TEMPLATE_COMPONENTS = [
TmplParentPointBoxComponent,
TmplParentPointCounterComponent,
TmplPdfComponent,
+ TmplProgressPathComponent,
TmplQRCodeComponent,
TmplRadioButtonGridComponent,
TmplRadioGroupComponent,
@@ -113,6 +116,7 @@ export const TEMPLATE_COMPONENTS = [
TmplTaskProgressBarComponent,
TmplTextAreaComponent,
TmplTextBoxComponent,
+ TmplTextBubbleComponent,
TmplTextComponent,
TmplTileComponent,
TmplTimerComponent,
@@ -164,6 +168,7 @@ export const TEMPLATE_COMPONENT_MAPPING: Record<
parent_point_box: TmplParentPointBoxComponent,
parent_point_counter: TmplParentPointCounterComponent,
pdf: TmplPdfComponent,
+ progress_path: TmplProgressPathComponent,
qr_code: TmplQRCodeComponent,
radio_button_grid: TmplRadioButtonGridComponent,
radio_group: TmplRadioGroupComponent,
@@ -183,6 +188,7 @@ export const TEMPLATE_COMPONENT_MAPPING: Record<
text: TmplTextComponent,
text_area: TmplTextAreaComponent,
text_box: TmplTextBoxComponent,
+ text_bubble: TmplTextBubbleComponent,
tile_component: TmplTileComponent,
timer: TmplTimerComponent,
title: TmplTitleComponent,
diff --git a/src/app/shared/components/template/components/layout/display-group/display-group.component.scss b/src/app/shared/components/template/components/layout/display-group/display-group.component.scss
index 986f3f9591..210125a5b7 100644
--- a/src/app/shared/components/template/components/layout/display-group/display-group.component.scss
+++ b/src/app/shared/components/template/components/layout/display-group/display-group.component.scss
@@ -42,24 +42,30 @@
margin: 1em 0 0 0;
}
-.display-group-wrapper[data-variant*="box"] {
- margin-top: var(--regular-margin);
- padding: var(--regular-padding);
- border-radius: var(--ion-border-radius-secondary);
- flex: 1;
+.display-group-wrapper {
+ &[data-variant~="box_gray"],
+ &[data-variant~="box_primary"],
+ &[data-variant~="box_secondary"] {
+ margin-top: var(--regular-margin);
+ padding: var(--regular-padding);
+ border-radius: var(--ion-border-radius-secondary);
+ flex: 1;
+ background-color: var(--background-color, transparent);
+ border: 2px solid var(--border-color, transparent);
+ }
&[data-variant~="box_gray"] {
- background-color: var(--ion-color-gray-100);
- border: 2px solid var(--ion-color-gray-300);
+ --background-color: var(--ion-color-gray-100);
+ --border-color: var(--ion-color-gray-300);
}
&[data-variant~="box_primary"] {
- background-color: var(--ion-color-primary-200);
- border: 2px solid var(--ion-color-primary-500);
+ --background-color: var(--ion-color-primary-200);
+ --border-color: var(--ion-color-primary-500);
}
&[data-variant~="box_secondary"] {
- background-color: var(--ion-color-secondary-200);
- border: 2px solid var(--ion-color-secondary-500);
+ --background-color: var(--ion-color-secondary-200);
+ --border-color: var(--ion-color-secondary-500);
}
}
diff --git a/src/app/shared/components/template/components/layout/display-group/display-group.component.ts b/src/app/shared/components/template/components/layout/display-group/display-group.component.ts
index a55449e69d..2eadf85834 100644
--- a/src/app/shared/components/template/components/layout/display-group/display-group.component.ts
+++ b/src/app/shared/components/template/components/layout/display-group/display-group.component.ts
@@ -4,9 +4,9 @@ import { getNumberParamFromTemplateRow, getStringParamFromTemplateRow } from "..
interface IDisplayGroupParams {
/** TEMPLATE PARAMETER: "variant" */
- variant: "box_gray" | "box_primary" | "box_secondary";
+ variant: "box_gray" | "box_primary" | "box_secondary" | "dashed_box";
/** TEMPLATE PARAMETER: "style". TODO: Various additional legacy styles, review and convert some to variants */
- style: "form" | "dashed_box" | "default" | string | null;
+ style: "form" | "default" | string | null;
/** TEMPLATE PARAMETER: "offset". Add a custom bottom margin */
offset: number;
}
@@ -34,15 +34,15 @@ export class TmplDisplayGroupComponent extends TemplateBaseComponent implements
this.params.offset = getNumberParamFromTemplateRow(this._row, "offset", 0);
this.params.variant = getStringParamFromTemplateRow(this._row, "variant", "")
.split(",")
- .join(" ") as IDisplayGroupParams["variant"];
- this.type = this.getTypeFromStyles(this.params.style || "");
+ .join(" ")
+ .concat(" " + this.params.style) as IDisplayGroupParams["variant"];
+ this.type = this.getTypeFromStyles();
}
- private getTypeFromStyles(styles: string) {
- if (styles) {
- if (styles.includes("form")) return "form";
- if (styles.includes("dashed_box")) return "dashed_box";
- }
+ private getTypeFromStyles() {
+ if (this.params.style?.includes("form") || this.params.variant?.includes("form")) return "form";
+ if (this.params.style?.includes("dashed_box") || this.params.variant?.includes("dashed_box"))
+ return "dashed_box";
return "default";
}
}
diff --git a/src/app/shared/components/template/components/layout/popup/popup.component.scss b/src/app/shared/components/template/components/layout/popup/popup.component.scss
index b8704ea611..69f38c2587 100644
--- a/src/app/shared/components/template/components/layout/popup/popup.component.scss
+++ b/src/app/shared/components/template/components/layout/popup/popup.component.scss
@@ -18,6 +18,10 @@
.popup-container {
height: var(--safe-area-height);
}
+ .close-button {
+ top: 10px;
+ right: 10px;
+ }
}
.popup-content {
margin: 30px auto;
@@ -39,8 +43,8 @@
}
.close-button {
position: absolute;
- top: 16px;
- right: 22px;
+ top: 18px;
+ right: 19px;
background: white;
width: 40px;
height: 40px;
diff --git a/src/app/shared/components/template/components/progress-path/progress-path.component.html b/src/app/shared/components/template/components/progress-path/progress-path.component.html
new file mode 100644
index 0000000000..b6784eded5
--- /dev/null
+++ b/src/app/shared/components/template/components/progress-path/progress-path.component.html
@@ -0,0 +1,30 @@
+
+ @for (childRow of _row.rows | filterDisplayComponent; track trackByRow($index, childRow)) {
+
+ }
+
diff --git a/src/app/shared/components/template/components/progress-path/progress-path.component.scss b/src/app/shared/components/template/components/progress-path/progress-path.component.scss
new file mode 100644
index 0000000000..99b3f87eec
--- /dev/null
+++ b/src/app/shared/components/template/components/progress-path/progress-path.component.scss
@@ -0,0 +1,72 @@
+$path-background: var(--progress-path-line-background, var(--ion-color-primary-200));
+$path-stroke-width: 28px;
+
+.progress-path-wrapper {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ margin: auto;
+ align-items: center;
+}
+
+.progress-path-child-wrapper {
+ $path-segment-spacing: 24px;
+
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ .path-segment {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ z-index: -1;
+ svg {
+ inline-size: 100%;
+ block-size: 100%;
+ stroke: $path-background;
+ stroke-width: $path-stroke-width;
+ }
+ // For right-to-left languages, flip the path to match the content position
+ &[data-language-direction~="rtl"] {
+ transform: scaleX(-1);
+ }
+ }
+
+ .progress-path-child-content-wrapper {
+ width: 100vw;
+ max-width: calc(var(--container-width) + 20px);
+ min-width: calc(var(--container-width) - 44px);
+ display: flex;
+ flex-direction: column;
+ }
+ .progress-path-child-content {
+ width: 150px;
+ align-self: flex-start;
+ }
+
+ // For alternate instances, mirror the way the content displays for a staggered effect
+ &.odd-index {
+ .progress-path-child-content {
+ align-self: flex-end;
+ }
+ .path-segment {
+ // Flip horizontally
+ transform: scaleX(-1);
+ margin-left: 0px;
+ margin-right: $path-segment-spacing;
+ // For right-to-left languages, flip the path to match the content position
+ &[data-language-direction~="rtl"] {
+ transform: none;
+ }
+ }
+ }
+
+ &:last-child {
+ .path-segment {
+ display: none;
+ }
+ }
+}
diff --git a/src/app/shared/components/template/components/progress-path/progress-path.component.spec.ts b/src/app/shared/components/template/components/progress-path/progress-path.component.spec.ts
new file mode 100644
index 0000000000..2f965f4ee2
--- /dev/null
+++ b/src/app/shared/components/template/components/progress-path/progress-path.component.spec.ts
@@ -0,0 +1,24 @@
+import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
+import { IonicModule } from "@ionic/angular";
+
+import { TmplProgressPathComponent } from "./progress-path.component";
+
+describe("TmplProgressPathComponent", () => {
+ let component: TmplProgressPathComponent;
+ let fixture: ComponentFixture
;
+
+ beforeEach(waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [TmplProgressPathComponent],
+ imports: [IonicModule.forRoot()],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TmplProgressPathComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }));
+
+ it("should create", () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/shared/components/template/components/progress-path/progress-path.component.ts b/src/app/shared/components/template/components/progress-path/progress-path.component.ts
new file mode 100644
index 0000000000..7e07642b12
--- /dev/null
+++ b/src/app/shared/components/template/components/progress-path/progress-path.component.ts
@@ -0,0 +1,94 @@
+import { Component, OnInit } from "@angular/core";
+import { TemplateBaseComponent } from "../base";
+import { getStringParamFromTemplateRow } from "src/app/shared/utils";
+import { TemplateTranslateService } from "../../services/template-translate.service";
+
+interface IProgressPathParams {
+ /** TEMPLATE_PARAMETER: "variant". Default "wavy" */
+ variant: "basic" | "wavy";
+}
+
+// HACK - hardcoded sizing values to make content fit reasonably well
+const SIZING = {
+ /** Total width for container */
+ widthPx: 364,
+ /** Target height for text content */
+ textContentHeight: 82,
+ /** Adjust x for task card overlap */
+ xOffset: 68,
+ /** Adjust y for task card overlap */
+ yOffset: 48,
+};
+
+@Component({
+ selector: "plh-progress-path",
+ templateUrl: "./progress-path.component.html",
+ styleUrls: ["./progress-path.component.scss"],
+})
+export class TmplProgressPathComponent extends TemplateBaseComponent implements OnInit {
+ private params: Partial = {};
+ private pathVariant: "basic" | "wavy";
+
+ public svgPath: string;
+ public svgViewBox: string;
+ public contentHeight: string;
+ public width = `${SIZING.widthPx}px`;
+
+ constructor(public templateTranslateService: TemplateTranslateService) {
+ super();
+ }
+
+ ngOnInit() {
+ this.getParams();
+ this.generateSVGPath(this.pathVariant);
+ }
+
+ private getParams() {
+ this.params.variant = getStringParamFromTemplateRow(this._row, "variant", "wavy")
+ .split(",")
+ .join(" ") as IProgressPathParams["variant"];
+ this.pathVariant = this.params.variant.includes("basic") ? "basic" : "wavy";
+ }
+
+ /**
+ * Generate a base SVG segment used to connect 2 progress items together
+ * Roughly a horizontal line and smooth bend, adjusted for sizing
+ */
+ private generateSVGPath(variant: "basic" | "wavy" = "wavy") {
+ // arbitrary values used to make base width/height fit
+ const { widthPx, xOffset, yOffset, textContentHeight } = SIZING;
+
+ // adjust viewbox to include both title content and 100px card (+overlap)
+ const viewboxHeight = textContentHeight + 128;
+
+ // SVG Generation (https://www.aleksandrhovhannisyan.com/blog/svg-tutorial/)
+
+ // M - start point (allow space for stroke width and content offset)
+ // h - horizontal line, relative length
+ // c - bezier curve (64 unit rounded)
+ // v - vertical line, relative length
+
+ // Basic generation, smooth
+ // https://svg-path-visualizer.netlify.app/#M%20128%2C128%0Ah%20384%20%0Ac%2064%2C0%2064%2C64%2064%2C64%0Av%20352
+ const basic = () =>
+ `
+ M ${xOffset},${yOffset}
+ h ${widthPx - 2 * xOffset - 32}
+ c 32,0 32,32 32,32
+ v ${viewboxHeight - yOffset - 32}
+ `.trim();
+
+ // Alt generation that is a bit more wavy
+ // https://svg-path-visualizer.netlify.app/#M%2064%2C56%0Ah%20208%0Ac%2048%2C0%2072%2C64%2048%2C128%0A
+ const wavy = () =>
+ `
+ M ${xOffset},${yOffset}
+ h ${widthPx - 2 * xOffset - 48}
+ c 48,0 72,64 48,${viewboxHeight - yOffset - 4}
+ `.trim();
+
+ this.svgPath = variant === "basic" ? basic() : wavy();
+ this.svgViewBox = `0 0 ${widthPx} ${viewboxHeight}`;
+ this.contentHeight = `${textContentHeight}px`;
+ }
+}
diff --git a/src/app/shared/components/template/components/task-card/task-card.component.html b/src/app/shared/components/template/components/task-card/task-card.component.html
index 1baa403747..3b926810d7 100644
--- a/src/app/shared/components/template/components/task-card/task-card.component.html
+++ b/src/app/shared/components/template/components/task-card/task-card.component.html
@@ -1,77 +1,124 @@
-
-
-
- {{ highlightedText }}
-
-
-
-
-
-
-
-
-
-
-
-
-
+@if (!variant.includes("circle")) {
+
+
+
+ {{ highlightedText }}
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
- {{ title }}
-
-
-
-
- {{ subtitle }}
-
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
-
-
-
+
+
+
+
+
+} @else {
+
+
+
-
+ @if (highlighted) {
+
+ {{ highlightedText }}
+
+ } @else {
+
+
+
+
+
+ }
+
-
+
+ @if (title) {
+
+ }
-
+}
diff --git a/src/app/shared/components/template/components/task-card/task-card.component.scss b/src/app/shared/components/template/components/task-card/task-card.component.scss
index f7ad8e718e..4cde12d868 100644
--- a/src/app/shared/components/template/components/task-card/task-card.component.scss
+++ b/src/app/shared/components/template/components/task-card/task-card.component.scss
@@ -44,6 +44,11 @@
align-items: center;
padding: var(--small-padding);
min-height: 56px;
+
+ plh-task-progress-bar {
+ display: none;
+ }
+
.content-wrapper {
height: 100%;
flex-direction: row-reverse;
@@ -103,44 +108,114 @@
padding-left: var(--small-padding);
}
}
+}
- .badge {
+.badge {
+ position: absolute;
+ top: -10px;
+ filter: drop-shadow(var(--ion-default-box-shadow));
+ &.highlighted-badge {
+ right: -10px;
+ padding: 5px 10px;
+ border-radius: var(--ion-border-radius-small);
+ background: var(--ion-color-secondary);
+ color: white;
+ font-weight: var(--font-weight-bold);
+ }
+ &.progress-badge {
+ right: -12px;
+ width: 36px;
+ }
+ .circle {
+ height: 36px;
+ width: 36px;
+ border-radius: 50%;
position: absolute;
- top: -10px;
- filter: drop-shadow(var(--ion-default-box-shadow));
- &.highlighted-badge {
- right: -10px;
- padding: 5px 10px;
- border-radius: var(--ion-border-radius-small);
- background: var(--ion-color-secondary);
- color: white;
- font-weight: var(--font-weight-bold);
+ z-index: 1;
+ &.completed {
+ background-color: var(--ion-color-green);
}
- &.progress-badge {
- right: -12px;
- width: 36px;
+ &.inProgress {
+ background-color: var(--ion-color-gray-light);
}
- .circle {
- height: 36px;
- width: 36px;
- border-radius: 50%;
+ }
+ .icon {
+ position: absolute;
+ z-index: 2;
+ width: 100%;
+ padding: 4px;
+ &.completed {
+ top: 2px;
+ padding: 8px;
+ }
+ }
+}
+
+.circle-card-wrapper {
+ $circle-width: 100px;
+
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+
+ // HACK: use custom CSS variable, set in progress-path component, to set flex alignment
+ // based on the index parity of the card component within progress-path.
+ // Default to center when not a child of progress-path
+ align-items: var(--progress-path-flex-align, center);
+
+ .image-and-badge-wrapper {
+ position: relative;
+ width: 100%;
+ border-radius: 50%;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ .badge {
position: absolute;
- z-index: 1;
- &.completed {
- background-color: var(--ion-color-green);
+ top: -6px;
+ right: 14px;
+ z-index: 2;
+ &.highlighted-badge {
+ right: -4px;
}
- &.inProgress {
- background-color: var(--ion-color-gray-light);
+ }
+
+ .circle-wrapper {
+ width: $circle-width;
+ height: $circle-width;
+ border-radius: 50%;
+ overflow: hidden;
+ background-color: white;
+ border: 1px solid rgba(black, 0.07);
+ filter: drop-shadow(var(--ion-default-box-shadow));
+
+ img {
+ width: 100%;
+ height: 100%;
+ clip-path: circle(#{$circle-width / 2 - 4px} at center);
}
}
- .icon {
+
+ plh-task-progress-bar {
position: absolute;
- z-index: 2;
- padding: 4px;
- &.completed {
- top: 2px;
- padding: 8px;
- }
+ display: none;
+ }
+ }
+ .title-wrapper {
+ position: absolute;
+ top: $circle-width;
+ min-width: 150px;
+ max-width: 240px;
+ padding: 0 12px;
+
+ p {
+ text-align: center;
+ line-height: var(--line-height-text);
+ font-size: var(--font-size-text-medium);
+ color: var(--ion-color-primary);
+ font-weight: var(--font-weight-bold);
}
}
}
diff --git a/src/app/shared/components/template/components/task-card/task-card.component.ts b/src/app/shared/components/template/components/task-card/task-card.component.ts
index 02f4aff973..b634413f48 100644
--- a/src/app/shared/components/template/components/task-card/task-card.component.ts
+++ b/src/app/shared/components/template/components/task-card/task-card.component.ts
@@ -115,10 +115,7 @@ export class TmplTaskCardComponent extends TemplateBaseComponent implements OnIn
ngOnInit() {
this.getParams();
- this.highlighted =
- this.taskGroupId && !this.taskId
- ? this.taskService.checkHighlightedTaskGroup(this.taskGroupId)
- : false;
+ this.highlighted = this.checkGroupHighlighted();
this.checkProgressStatus();
}
@@ -161,4 +158,11 @@ export class TmplTaskCardComponent extends TemplateBaseComponent implements OnIn
this.triggerActions("completed");
}
}
+
+ private checkGroupHighlighted() {
+ if (this.taskGroupId && !this.taskId) {
+ return this.taskService.checkHighlightedTaskGroup(this.taskGroupId);
+ }
+ return false;
+ }
}
diff --git a/src/app/shared/components/template/components/task-progress-bar/task-progress-bar.component.ts b/src/app/shared/components/template/components/task-progress-bar/task-progress-bar.component.ts
index ff5ae3380c..9b6162eeba 100644
--- a/src/app/shared/components/template/components/task-progress-bar/task-progress-bar.component.ts
+++ b/src/app/shared/components/template/components/task-progress-bar/task-progress-bar.component.ts
@@ -138,7 +138,7 @@ export class TmplTaskProgressBarComponent
this.params.completedField = this.completedField;
this.params.progressUnitsName = this.progressUnitsName;
this.params.showText = this.showText;
- this.params.completedColumnName = null;
+ this.params.completedColumnName = "completed";
this.params.completedFieldColumnName = "completed_field";
}
}
diff --git a/src/app/shared/components/template/components/text-bubble/text-bubble.component.html b/src/app/shared/components/template/components/text-bubble/text-bubble.component.html
new file mode 100644
index 0000000000..41b01cb9cc
--- /dev/null
+++ b/src/app/shared/components/template/components/text-bubble/text-bubble.component.html
@@ -0,0 +1,25 @@
+
+
+ @if (_row.value) {
+
+ {{ _row.value }}
+
+ }
+ @for (childRow of _row.rows | filterDisplayComponent; track trackByRow($index, childRow)) {
+
+ }
+
+
+
diff --git a/src/app/shared/components/template/components/text-bubble/text-bubble.component.scss b/src/app/shared/components/template/components/text-bubble/text-bubble.component.scss
new file mode 100644
index 0000000000..6c80055a6e
--- /dev/null
+++ b/src/app/shared/components/template/components/text-bubble/text-bubble.component.scss
@@ -0,0 +1,121 @@
+// Adapted from https://www.smashingmagazine.com/2024/03/modern-css-tooltips-speech-bubbles-part1/
+// WIth code available here https://css-generators.com/tooltip-speech-bubble/
+// NOTE - when copying css border-image double-slash // misinterpreted so replace with / 0 /
+
+$imageSize: 64px;
+
+.container {
+ position: relative;
+ width: fit-content;
+ min-width: $imageSize;
+ margin-bottom: calc($imageSize + 8px);
+
+ &[data-position="right"] {
+ float: right;
+ }
+
+ &[data-variant~="gray"] {
+ --background-color: var(--ion-color-gray-100);
+ --border-color: var(--ion-color-gray-300);
+ }
+
+ &[data-variant~="primary"] {
+ --background-color: var(--ion-color-primary-200);
+ --border-color: var(--ion-color-primary-500);
+ }
+
+ &[data-variant~="secondary"] {
+ --background-color: var(--ion-color-secondary-200);
+ --border-color: var(--ion-color-secondary-500);
+ }
+
+ &[data-variant~="no_border"] {
+ --border-color: transparent;
+ }
+}
+
+.speaker-image {
+ position: absolute;
+ bottom: -$imageSize - 8px;
+ width: $imageSize;
+ height: $imageSize;
+ &[data-position="left"] {
+ left: 8px;
+ }
+ &[data-position="right"] {
+ right: 8px;
+ }
+}
+
+.text-bubble {
+ padding: var(--large-padding);
+ p {
+ line-height: inherit;
+ margin: 0;
+ font-size: var(--font-size-text-large);
+ color: var(--ion-color-primary);
+ }
+}
+
+// This creates the bubble shape, including the tail
+.text-bubble {
+ /* triangle dimension */
+ --a: 75deg; /* angle */
+ --h: 1em; /* height */
+
+ --p: 50%; /* triangle position (0%:left 100%:right) */
+ &[data-position="left"] {
+ --p: calc(0% + #{$imageSize} + 12px);
+ }
+ &[data-position="right"] {
+ --p: calc(100% - #{$imageSize} - 12px);
+ }
+ --r: var(--ion-border-radius-secondary); /* border radius */
+ --b: 2px; /* border width */
+ --c1: var(--border-color, var(--ion-color-gray-300)); /* border color */
+ --c2: var(--background-color, var(--ion-color-gray-100)); /* background color */
+
+ border-radius: var(--r) var(--r) min(var(--r), 100% - var(--p) - var(--h) * tan(var(--a) / 2))
+ min(var(--r), var(--p) - var(--h) * tan(var(--a) / 2)) / var(--r);
+ clip-path: polygon(
+ 0 100%,
+ 0 0,
+ 100% 0,
+ 100% 100%,
+ min(100%, var(--p) + var(--h) * tan(var(--a) / 2)) 100%,
+ var(--p) calc(100% + var(--h)),
+ max(0%, var(--p) - var(--h) * tan(var(--a) / 2)) 100%
+ );
+ background: var(--c1);
+ border-image: conic-gradient(var(--c1) 0 0) fill 0 / var(--r)
+ max(0%, 100% - var(--p) - var(--h) * tan(var(--a) / 2)) 0
+ max(0%, var(--p) - var(--h) * tan(var(--a) / 2)) / 0 0 var(--h) 0;
+ position: relative;
+}
+// This creates the border around the bubble
+.text-bubble:before {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ inset: 0;
+ padding: var(--b);
+ border-radius: inherit;
+ clip-path: polygon(
+ 0 100%,
+ 0 0,
+ 100% 0,
+ 100% 100%,
+ min(
+ 100% - var(--b),
+ var(--p) + var(--h) * tan(var(--a) / 2) - var(--b) * tan(45deg - var(--a) / 4)
+ )
+ calc(100% - var(--b)),
+ var(--p) calc(100% + var(--h) - var(--b) / sin(var(--a) / 2)),
+ max(var(--b), var(--p) - var(--h) * tan(var(--a) / 2) + var(--b) * tan(45deg - var(--a) / 4))
+ calc(100% - var(--b))
+ );
+ background: var(--c2) content-box;
+ border-image: conic-gradient(var(--c2) 0 0) fill 0 / var(--r)
+ max(var(--b), 100% - var(--p) - var(--h) * tan(var(--a) / 2)) 0
+ max(var(--b), var(--p) - var(--h) * tan(var(--a) / 2)) / 0 0 var(--h) 0;
+}
diff --git a/src/app/shared/components/template/components/text-bubble/text-bubble.component.ts b/src/app/shared/components/template/components/text-bubble/text-bubble.component.ts
new file mode 100644
index 0000000000..7c05b1a18d
--- /dev/null
+++ b/src/app/shared/components/template/components/text-bubble/text-bubble.component.ts
@@ -0,0 +1,40 @@
+import { Component, OnInit, ViewEncapsulation } from "@angular/core";
+import { TemplateBaseComponent } from "../base";
+import { getStringParamFromTemplateRow } from "src/app/shared/utils";
+
+interface ITextBubbleParams {
+ /** TEMPLATE PARAMETER: "speaker_image_asset". The path to an image to be used as the speaker */
+ speakerImageAsset: string;
+ /** TEMPLATE PARAMETER: "speaker_position". The position of the speaker image and speech bubble tail */
+ speakerPosition: "left" | "right";
+ /** TEMPLATE PARAMETER: "variant" */
+ variant: "gray" | "primary" | "secondary" | "no-border";
+}
+
+@Component({
+ selector: "tmpl-text-bubble",
+ templateUrl: "text-bubble.component.html",
+ styleUrl: "text-bubble.component.scss",
+})
+export class TmplTextBubbleComponent extends TemplateBaseComponent implements OnInit {
+ params: Partial
= {};
+ ngOnInit() {
+ this.getParams();
+ }
+
+ getParams() {
+ this.params.speakerImageAsset = getStringParamFromTemplateRow(
+ this._row,
+ "speaker_image_asset",
+ ""
+ );
+ this.params.speakerPosition = getStringParamFromTemplateRow(
+ this._row,
+ "speaker_position",
+ "left"
+ ) as "left" | "right";
+ this.params.variant = getStringParamFromTemplateRow(this._row, "variant", "")
+ .split(",")
+ .join(" ") as ITextBubbleParams["variant"];
+ }
+}
diff --git a/src/app/shared/components/template/components/toggle-bar/toggle-bar.html b/src/app/shared/components/template/components/toggle-bar/toggle-bar.html
index 68a782627c..825e3dd57d 100644
--- a/src/app/shared/components/template/components/toggle-bar/toggle-bar.html
+++ b/src/app/shared/components/template/components/toggle-bar/toggle-bar.html
@@ -12,11 +12,13 @@
[class.show-tick-cross]="params.showTickAndCross"
>
+ >
+
diff --git a/src/app/shared/components/template/components/toggle-bar/toggle-bar.scss b/src/app/shared/components/template/components/toggle-bar/toggle-bar.scss
index 4dcaa58ea1..62b8af8ef0 100644
--- a/src/app/shared/components/template/components/toggle-bar/toggle-bar.scss
+++ b/src/app/shared/components/template/components/toggle-bar/toggle-bar.scss
@@ -8,32 +8,17 @@ ion-toggle {
}
ion-toggle {
- --track-background: #b4b7b7;
- --handle-spacing: 2px;
- --track-background-checked: var(--ion-color-primary, "darkblue");
- --handle-background: var(--ion-item-background, #fefefe);
- --handle-background-checked: #fff;
+ --track-background: var(--ion-color-gray-200);
+ --track-background-checked: var(--ion-color-primary-500);
+ --handle-background: var(--ion-item-background, white);
--padding-inline: $togglePadding;
-}
-.show-tick-cross {
- $iconPadding: 3px;
- ion-toggle[aria-checked="true"]::before,
- ion-toggle[aria-checked="false"]::after {
- position: absolute;
- top: $togglePadding + $iconPadding;
- left: $togglePadding + $iconPadding;
- color: white;
- z-index: 1;
- content: " ";
- width: $toggleWidth - 2 * $iconPadding;
- height: $toggleHeight - 2 * $iconPadding;
- }
- ion-toggle[aria-checked="true"]::before {
- background: url(/assets/icon/shared/tick.svg) no-repeat left / contain;
+ &[data-mode~="ios"] {
+ --handle-background-checked: white;
}
- ion-toggle[aria-checked="false"]::after {
- background: url(/assets/icon/shared/cross.svg) no-repeat right / contain;
+ &[data-mode~="md"] {
+ --handle-background-checked: var(--ion-color-primary);
+ --padding-inline: $togglePadding;
}
}
@@ -45,9 +30,13 @@ ion-toggle {
&[data-param-style~="in_button"],
&[data-variant~="in_button"] {
- flex-direction: row-reverse;
ion-toggle {
- transform: translate($togglePadding, $togglePadding);
+ transform: translate(12px, -4px);
+ }
+ &[data-variant~="ios"] {
+ ion-toggle {
+ transform: translate(4px, 4px);
+ }
}
}
diff --git a/src/app/shared/components/template/components/toggle-bar/toggle-bar.ts b/src/app/shared/components/template/components/toggle-bar/toggle-bar.ts
index 92e2ae2b9c..79195b7c3b 100644
--- a/src/app/shared/components/template/components/toggle-bar/toggle-bar.ts
+++ b/src/app/shared/components/template/components/toggle-bar/toggle-bar.ts
@@ -1,4 +1,5 @@
import { Component, OnInit } from "@angular/core";
+import { Capacitor } from "@capacitor/core";
import { TemplateBaseComponent } from "../base";
import { ITemplateRowProps } from "../../models";
import {
@@ -7,11 +8,16 @@ import {
} from "src/app/shared/utils";
interface IToggleParams {
- /** TEMPLATE PARAMETER: "variant" */
- variant: "" | "icon" | "in_button";
+ /**
+ * TEMPLATE PARAMETER: "variant". Setting "ios" or "android" will style the toggle to match the respective
+ * platform, otherwise the default is to match the current device platform, using "android" on web.
+ * */
+ variant: "" | "icon" | "in_button" | "ios" | "android";
/** TEMPLATE PARAMETER: "style". Legacy, use "variant" instead. */
style: string;
- /** TEMPLATE PARAMETER: "show_tick_and_cross" */
+ /** TEMPLATE PARAMETER: "show_icons". Display icons within toggle to represent enabled/disabled state. Default true. */
+ showIcons: boolean;
+ /** TEMPLATE PARAMETER: "show_tick_and_cross". Legacy, use "show-icons" instead */
showTickAndCross: boolean;
/** TEMPLATE PARAMETER: "position". Default "left" */
position: "left" | "center" | "right";
@@ -35,6 +41,11 @@ export class TmplToggleBarComponent
implements ITemplateRowProps, OnInit
{
params: Partial
= {};
+ /**
+ * The ion-toggle component uses "md" ("material design") and "ios" to refer to visual styles of the component
+ * corresponding to the respective platforms. See docs here: https://ionicframework.com/docs/api/toggle
+ */
+ platformVariant: "ios" | "md" = "ios";
/** @ignore */
variantMap: { icon: boolean };
@@ -66,15 +77,34 @@ export class TmplToggleBarComponent
"show_tick_and_cross",
true
);
+ this.params.showIcons = getBooleanParamFromTemplateRow(this._row, "show_icons", true);
this.params.style = getStringParamFromTemplateRow(this._row, "style", "");
this.params.variant = getStringParamFromTemplateRow(this._row, "variant", "")
.split(",")
.join(" ") as IToggleParams["variant"];
+ this.setPlatformVariant(this.params.variant);
this.populateVariantMap();
this.params.iconTrue = getStringParamFromTemplateRow(this._row, "icon_true_asset", "");
this.params.iconFalse = getStringParamFromTemplateRow(this._row, "icon_false_asset", "");
}
+ /**
+ * Use the platform variant explicitly set by the author,
+ * otherwise default to "ios" on iOS, and "md" on Android and web
+ * @param variantString A space-separated string of variants
+ */
+ private setPlatformVariant(variantString: string) {
+ const variantArray = variantString.split(" ");
+
+ if (variantArray.includes("ios")) {
+ this.platformVariant = "ios";
+ } else if (variantArray.includes("android")) {
+ this.platformVariant = "md";
+ } else {
+ this.platformVariant = Capacitor.getPlatform() === "ios" ? "ios" : "md";
+ }
+ }
+
private populateVariantMap() {
const variantArray = this.params.variant.split(" ");
this.variantMap = {
diff --git a/src/app/shared/components/template/services/instance/template-action.service.ts b/src/app/shared/components/template/services/instance/template-action.service.ts
index 87a5ee1137..7f8b8231d5 100644
--- a/src/app/shared/components/template/services/instance/template-action.service.ts
+++ b/src/app/shared/components/template/services/instance/template-action.service.ts
@@ -15,7 +15,6 @@ import { DBSyncService } from "src/app/shared/services/db/db-sync.service";
import { AuthService } from "src/app/shared/services/auth/auth.service";
import { SkinService } from "src/app/shared/services/skin/skin.service";
import { ThemeService } from "src/app/feature/theme/services/theme.service";
-import { TaskService } from "src/app/shared/services/task/task.service";
import { getGlobalService } from "src/app/shared/services/global.service";
import { SyncServiceBase } from "src/app/shared/services/syncService.base";
import { TemplateActionRegistry } from "./template-action.registry";
@@ -35,7 +34,10 @@ export class TemplateActionService extends SyncServiceBase {
private actionsQueue: FlowTypes.TemplateRowAction[] = [];
private actionsQueueProcessing$ = new BehaviorSubject(false);
- constructor(private injector: Injector, public container?: TemplateContainerComponent) {
+ constructor(
+ private injector: Injector,
+ public container?: TemplateContainerComponent
+ ) {
super("TemplateAction");
}
// Retrive all services on demand from global injector
@@ -72,9 +74,7 @@ export class TemplateActionService extends SyncServiceBase {
private get themeService() {
return getGlobalService(this.injector, ThemeService);
}
- private get taskService() {
- return getGlobalService(this.injector, TaskService);
- }
+
private get campaignService() {
return getGlobalService(this.injector, CampaignService);
}
@@ -84,11 +84,7 @@ export class TemplateActionService extends SyncServiceBase {
}
private async ensurePublicServicesReady() {
- await this.ensureAsyncServicesReady([
- this.templateTranslateService,
- this.dbSyncService,
- this.taskService,
- ]);
+ await this.ensureAsyncServicesReady([this.templateTranslateService, this.dbSyncService]);
this.ensureSyncServicesReady([
this.serverService,
this.templateNavService,
@@ -115,6 +111,10 @@ export class TemplateActionService extends SyncServiceBase {
if (!this.container?.parent) {
await this.templateNavService.handleNavActionsFromChild(actions, this.container);
}
+ // HACK - ensure components checked for updates after processing
+ if (this.container?.cdr) {
+ this.container.cdr.markForCheck();
+ }
}
/** Optional method child component can add to handle post-action callback */
public async handleActionsCallback(actions: FlowTypes.TemplateRowAction[], results: any) {}
diff --git a/src/app/shared/components/template/services/template-calc.service.ts b/src/app/shared/components/template/services/template-calc.service.ts
index b9c577da4e..42e4ce5b81 100644
--- a/src/app/shared/components/template/services/template-calc.service.ts
+++ b/src/app/shared/components/template/services/template-calc.service.ts
@@ -1,15 +1,19 @@
import { IFunctionHashmap, IConstantHashmap } from "src/app/shared/utils";
-
import { Injectable } from "@angular/core";
+import { Device, DeviceInfo } from "@capacitor/device";
import * as date_fns from "date-fns";
import { ServerService } from "src/app/shared/services/server/server.service";
import { DataEvaluationService } from "src/app/shared/services/data/data-evaluation.service";
import { AsyncServiceBase } from "src/app/shared/services/asyncService.base";
import { PLH_CALC_FUNCTIONS } from "./template-calc-functions/plh-calc-functions";
import { CORE_CALC_FUNCTIONS } from "./template-calc-functions/core-calc-functions";
+import { UserMetaService } from "src/app/shared/services/userMeta/userMeta.service";
+import { LocalStorageService } from "src/app/shared/services/local-storage/local-storage.service";
@Injectable({ providedIn: "root" })
export class TemplateCalcService extends AsyncServiceBase {
+ private app_user_id: string;
+ private device_info: DeviceInfo;
/** list of all variables accessible directly within calculations */
private calcContext: ICalcContext;
@@ -20,14 +24,18 @@ export class TemplateCalcService extends AsyncServiceBase {
constructor(
private serverService: ServerService,
- private dataEvaluationService: DataEvaluationService
+ private dataEvaluationService: DataEvaluationService,
+ private localStorageService: LocalStorageService,
+ private userMetaService: UserMetaService
) {
super("TemplateCalc");
this.registerInitFunction(this.initialise);
}
private async initialise() {
- this.ensureSyncServicesReady([this.serverService]);
- await this.ensureAsyncServicesReady([this.dataEvaluationService]);
+ this.ensureSyncServicesReady([this.serverService, this.localStorageService]);
+ await this.ensureAsyncServicesReady([this.dataEvaluationService, this.userMetaService]);
+ await this.setUserMetaData();
+ this.getCalcContext();
}
/** Provide calc context, initialising only once */
@@ -57,11 +65,20 @@ export class TemplateCalcService extends AsyncServiceBase {
calc: (v: any) => v, // include simple function so @calc(...) returns the value already parsed inside
app_day: this.dataEvaluationService.data.app_day,
app_first_launch: this.dataEvaluationService.data.first_app_launch,
- app_user_id: this.serverService.app_user_id,
- device_info: this.serverService.device_info,
+ app_user_id: this.app_user_id,
+ device_info: this.device_info,
};
}
+ private async setUserMetaData() {
+ if (!this.device_info) {
+ this.device_info = await Device.getInfo();
+ }
+ if (!this.app_user_id) {
+ this.app_user_id = this.localStorageService.getProtected("APP_USER_ID");
+ }
+ }
+
/**
* Provide a list of variables that can be accessed directly within calculations
*
diff --git a/src/app/shared/components/template/template-container.component.ts b/src/app/shared/components/template/template-container.component.ts
index cb53da0dcf..1fe5170441 100644
--- a/src/app/shared/components/template/template-container.component.ts
+++ b/src/app/shared/components/template/template-container.component.ts
@@ -61,7 +61,7 @@ export class TemplateContainerComponent implements OnInit, OnDestroy, ITemplateC
private componentDestroyed$ = new Subject();
debugMode: boolean;
- private get cdr() {
+ public get cdr() {
return this.injector.get(ChangeDetectorRef);
}
diff --git a/src/app/shared/model/index.ts b/src/app/shared/model/index.ts
index e84c4f4869..a6da3fa740 100644
--- a/src/app/shared/model/index.ts
+++ b/src/app/shared/model/index.ts
@@ -1 +1,4 @@
-export * from "data-models";
+// Limited re-export of some types from data-models for local use
+
+export { FlowTypes } from "data-models/flowTypes";
+export type { IAppConfig } from "data-models/appConfig";
diff --git a/src/app/shared/services/analytics/analytics.module.ts b/src/app/shared/services/analytics/analytics.module.ts
new file mode 100644
index 0000000000..24f0e01b17
--- /dev/null
+++ b/src/app/shared/services/analytics/analytics.module.ts
@@ -0,0 +1,45 @@
+import { NgModule } from "@angular/core";
+
+import {
+ MATOMO_CONFIGURATION,
+ MatomoConfiguration,
+ provideMatomo,
+ withRouter,
+} from "ngx-matomo-client";
+
+import { IDeploymentRuntimeConfig } from "packages/data-models";
+import { DEPLOYMENT_CONFIG } from "../deployment/deployment.service";
+import { environment } from "src/environments/environment";
+
+/** When running locally can configure to target local running containing (if required) */
+const devConfig: MatomoConfiguration = {
+ disabled: true,
+ trackerUrl: "http://localhost/analytics",
+ siteId: 1,
+};
+
+/**
+ * When configuring the analytics module
+ * This should be imported into the main app.module.ts
+ */
+@NgModule({
+ imports: [],
+ providers: [
+ provideMatomo(null, withRouter()),
+ // Dynamically provide the configuration used by the matomo provider so that it can
+ // access deployment config (injected from token)
+ {
+ provide: MATOMO_CONFIGURATION,
+ useFactory: (deploymentConfig: IDeploymentRuntimeConfig): MatomoConfiguration => {
+ if (environment.production) {
+ const { enabled, endpoint, siteId } = deploymentConfig.analytics;
+ return { disabled: !enabled, siteId, trackerUrl: endpoint };
+ } else {
+ return devConfig;
+ }
+ },
+ deps: [DEPLOYMENT_CONFIG],
+ },
+ ],
+})
+export class AnalyticsModule {}
diff --git a/src/app/shared/services/analytics/index.ts b/src/app/shared/services/analytics/index.ts
new file mode 100644
index 0000000000..4e8738bfb5
--- /dev/null
+++ b/src/app/shared/services/analytics/index.ts
@@ -0,0 +1,2 @@
+export * from "./analytics.module";
+export * from "./analytics.service";
diff --git a/src/app/shared/services/app-config/app-config.service.spec.ts b/src/app/shared/services/app-config/app-config.service.spec.ts
index 928c7d276b..2b73be5c0f 100644
--- a/src/app/shared/services/app-config/app-config.service.spec.ts
+++ b/src/app/shared/services/app-config/app-config.service.spec.ts
@@ -3,30 +3,84 @@ import { TestBed } from "@angular/core/testing";
import { AppConfigService } from "./app-config.service";
import { BehaviorSubject } from "rxjs/internal/BehaviorSubject";
import { IAppConfig } from "../../model";
+import { signal } from "@angular/core";
+import { DeploymentService } from "../deployment/deployment.service";
+import {
+ getDefaultAppConfig,
+ IAppConfigOverride,
+ IDeploymentRuntimeConfig,
+} from "packages/data-models";
+import { deepMergeObjects } from "../../utils";
+import { firstValueFrom } from "rxjs/internal/firstValueFrom";
+import { MockDeploymentService } from "../deployment/deployment.service.spec";
/** Mock calls for field values from the template field service to return test data */
export class MockAppConfigService implements Partial {
+ appConfig = signal(undefined as any);
appConfig$ = new BehaviorSubject(undefined as any);
// allow additional specs implementing service to provide their own partial appConfig
- constructor(mockAppConfig: Partial = {}) {
- this.appConfig$.next(mockAppConfig as any);
+ constructor(private mockAppConfig: Partial = {}) {
+ this.setAppConfig();
}
public ready(timeoutValue?: number) {
return true;
}
+
+ public setAppConfig(overrides: IAppConfigOverride = {}) {
+ // merge onto empty object to avoid shared references across tests
+ const mergedConfig = deepMergeObjects({}, this.mockAppConfig, overrides) as IAppConfig;
+ this.appConfig$.next(mergedConfig);
+ this.appConfig.set(mergedConfig);
+ }
}
+const MOCK_DEPLOYMENT_CONFIG: Partial = {
+ app_config: { APP_FOOTER_DEFAULTS: { templateName: "mock_footer" } },
+};
+
+/**
+ * Call standalone tests via:
+ * yarn ng test --include src/app/shared/services/app-config/app-config.service.spec.ts
+ */
describe("AppConfigService", () => {
let service: AppConfigService;
beforeEach(() => {
- TestBed.configureTestingModule({});
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: DeploymentService, useValue: new MockDeploymentService(MOCK_DEPLOYMENT_CONFIG) },
+ ],
+ });
service = TestBed.inject(AppConfigService);
});
- it("should be created", () => {
- expect(service).toBeTruthy();
+ it("applies default config overrides on init", () => {
+ expect(service.appConfig().APP_HEADER_DEFAULTS.title).toEqual(
+ getDefaultAppConfig().APP_HEADER_DEFAULTS.title
+ );
+ });
+
+ it("applies deployment-specific config overrides on init", () => {
+ expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("mock_footer");
+ });
+
+ it("applies overrides to app config", () => {
+ service.setAppConfig({ APP_HEADER_DEFAULTS: { title: "updated" } });
+ expect(service.appConfig().APP_HEADER_DEFAULTS).toEqual({
+ ...getDefaultAppConfig().APP_HEADER_DEFAULTS,
+ title: "updated",
+ });
+ // also ensure doesn't unset default deployment
+ expect(service.appConfig().APP_FOOTER_DEFAULTS.templateName).toEqual("mock_footer");
+ });
+
+ it("emits partial changes on app config update", async () => {
+ firstValueFrom(service.changes$).then((v) => {
+ expect(v).toEqual({ APP_HEADER_DEFAULTS: { title: "partial changes" } });
+ });
+
+ service.setAppConfig({ APP_HEADER_DEFAULTS: { title: "partial changes" } });
});
});
diff --git a/src/app/shared/services/app-config/app-config.service.ts b/src/app/shared/services/app-config/app-config.service.ts
index 25a8a3ea65..04c6bd3dfe 100644
--- a/src/app/shared/services/app-config/app-config.service.ts
+++ b/src/app/shared/services/app-config/app-config.service.ts
@@ -1,32 +1,42 @@
-import { Injectable } from "@angular/core";
+import { Injectable, signal } from "@angular/core";
import { getDefaultAppConfig, IAppConfig, IAppConfigOverride } from "data-models";
import { BehaviorSubject } from "rxjs";
-import { environment } from "src/environments/environment";
import { deepMergeObjects, RecursivePartial, trackObservableObjectChanges } from "../../utils";
-import clone from "clone";
import { SyncServiceBase } from "../syncService.base";
import { startWith } from "rxjs/operators";
import { Observable } from "rxjs";
+import { DeploymentService } from "../deployment/deployment.service";
+import { updateRoutingDefaults } from "./app-config.utils";
+import { Router } from "@angular/router";
@Injectable({
providedIn: "root",
})
export class AppConfigService extends SyncServiceBase {
- deploymentOverrides: IAppConfigOverride = (environment.deploymentConfig as any).app_config || {};
- /** List of constants provided by data-models combined with deployment-specific overrides and skin-specific overrides */
- appConfig$ = new BehaviorSubject(undefined as any);
+ /**
+ * Initial config is generated by merging default app config with deployment-specific overrides
+ * It is accessed via a read-only getter to avoid update from methods
+ **/
+ private readonly initialConfig: IAppConfig = deepMergeObjects(
+ getDefaultAppConfig(),
+ this.deploymentService.config.app_config
+ );
- /** Tracking observable of deep changes to app config, exposed in `changes` public method */
- private appConfigChanges$: Observable>;
+ /** Signal representation of current appConfig value */
+ public appConfig = signal(this.initialConfig);
- APP_CONFIG: IAppConfig;
- deploymentAppConfig: IAppConfig;
+ /**
+ * @deprecated - prefer use of config signal and computed/effect bindings
+ * List of constants provided by data-models combined with deployment-specific overrides and skin-specific overrides
+ **/
+ public appConfig$ = new BehaviorSubject(this.initialConfig);
- public get value() {
- return this.appConfig$.value;
- }
+ /** Tracking observable of deep changes to app config, exposed in `changes` public method */
+ private appConfigChanges$: Observable>;
/**
+ * @deprecated - prefer use of config signal and computed/effect bindings
+ *
* Track deep object diff of app config changes.
* Creates subject on demand, so that multiple listeners can efficiently subscribe to changes
*/
@@ -37,37 +47,43 @@ export class AppConfigService extends SyncServiceBase {
return this.appConfigChanges$;
}
- /** Track deep object diff of app config changes, including full initial value */
+ /**
+ * @deprecated - prefer use of config signal and computed/effect bindings
+ * Track deep object diff of app config changes, including full initial value
+ * */
public get changesWithInitialValue$() {
- return this.changes$.pipe(startWith(this.value));
+ return this.changes$.pipe(startWith(this.appConfig()));
}
- constructor() {
+ constructor(
+ private deploymentService: DeploymentService,
+ private router: Router
+ ) {
super("AppConfig");
this.initialise();
}
+ /** When service initialises load initial config to trigger any side-effects */
private initialise() {
- this.APP_CONFIG = getDefaultAppConfig();
- // Store app config with deployment overrides applied, to be merged with additional overrides when applied
- this.deploymentAppConfig = this.applyAppConfigOverrides(
- this.APP_CONFIG,
- this.deploymentOverrides
- );
- this.updateAppConfig(this.deploymentOverrides);
+ this.setAppConfig(this.initialConfig);
}
- public updateAppConfig(overrides: IAppConfigOverride) {
- // Clone this.deploymentAppConfig so that the original is unaffected by deepMergeObjects()
- const appConfigWithOverrides = this.applyAppConfigOverrides(
- clone(this.deploymentAppConfig),
- overrides
- );
- this.APP_CONFIG = appConfigWithOverrides;
- this.appConfig$.next(appConfigWithOverrides);
+ /**
+ * Generate a complete app config by deep-merging app config overrides
+ * with the initial config
+ */
+ public setAppConfig(overrides: IAppConfigOverride = {}) {
+ // Ignore case where no overrides provides or overrides already applied
+ if (Object.keys(overrides).length === 0) return;
+ const mergedConfig = deepMergeObjects({} as IAppConfig, this.initialConfig, overrides);
+ this.handleConfigSideEffects(overrides, mergedConfig);
+ this.appConfig.set(mergedConfig);
+ this.appConfig$.next(mergedConfig);
}
- private applyAppConfigOverrides(appConfig: IAppConfig, overrides: IAppConfigOverride) {
- return deepMergeObjects(appConfig, overrides);
+ private handleConfigSideEffects(overrides: IAppConfigOverride = {}, config: IAppConfig) {
+ if (overrides.APP_ROUTE_DEFAULTS) {
+ updateRoutingDefaults(config.APP_ROUTE_DEFAULTS, this.router);
+ }
}
}
diff --git a/src/app/shared/services/app-config/app-config.utils.ts b/src/app/shared/services/app-config/app-config.utils.ts
new file mode 100644
index 0000000000..1b0c178f20
--- /dev/null
+++ b/src/app/shared/services/app-config/app-config.utils.ts
@@ -0,0 +1,17 @@
+import { Router, Routes } from "@angular/router";
+import { IAppConfig } from "packages/data-models";
+import { APP_FEATURE_ROUTES } from "src/app/app-routing.module";
+
+/**
+ * Update app routing to include redirects, home and fallback routes specified in config
+ */
+export const updateRoutingDefaults = (config: IAppConfig["APP_ROUTE_DEFAULTS"], router: Router) => {
+ const routes: Routes = [
+ ...APP_FEATURE_ROUTES,
+ ...config.redirects,
+ { path: "", redirectTo: config.home_route, pathMatch: "full" },
+ { path: "**", redirectTo: config.fallback_route },
+ ];
+
+ return router.resetConfig(routes);
+};
diff --git a/src/app/shared/services/auth/auth.service.ts b/src/app/shared/services/auth/auth.service.ts
index 2f85b1ae32..affb1e16f9 100644
--- a/src/app/shared/services/auth/auth.service.ts
+++ b/src/app/shared/services/auth/auth.service.ts
@@ -2,11 +2,11 @@ import { Injectable } from "@angular/core";
import { FirebaseAuthentication, User } from "@capacitor-firebase/authentication";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { filter } from "rxjs/operators";
-import { environment } from "src/environments/environment";
import { SyncServiceBase } from "../syncService.base";
import { TemplateActionRegistry } from "../../components/template/services/instance/template-action.registry";
import { FirebaseService } from "../firebase/firebase.service";
import { LocalStorageService } from "../local-storage/local-storage.service";
+import { DeploymentService } from "../deployment/deployment.service";
@Injectable({
providedIn: "root",
@@ -18,13 +18,14 @@ export class AuthService extends SyncServiceBase {
constructor(
private templateActionRegistry: TemplateActionRegistry,
private firebaseService: FirebaseService,
- private localStorageService: LocalStorageService
+ private localStorageService: LocalStorageService,
+ private deploymentService: DeploymentService
) {
super("Auth");
this.initialise();
}
private initialise() {
- const { firebase } = environment.deploymentConfig;
+ const { firebase } = this.deploymentService.config;
if (firebase?.auth?.enabled && this.firebaseService.app) {
this.addAuthListeners();
this.registerTemplateActionHandlers();
diff --git a/src/app/shared/services/crashlytics/crashlytics.service.ts b/src/app/shared/services/crashlytics/crashlytics.service.ts
index 9e6b2f0ad7..a6f7010f7a 100644
--- a/src/app/shared/services/crashlytics/crashlytics.service.ts
+++ b/src/app/shared/services/crashlytics/crashlytics.service.ts
@@ -3,7 +3,7 @@ import { FirebaseCrashlytics } from "@capacitor-firebase/crashlytics";
import { Capacitor } from "@capacitor/core";
import { Device } from "@capacitor/device";
import { AsyncServiceBase } from "../asyncService.base";
-import { environment } from "src/environments/environment";
+import { DeploymentService } from "../deployment/deployment.service";
@Injectable({
providedIn: "root",
@@ -14,13 +14,13 @@ import { environment } from "src/environments/environment";
* https://github.com/capawesome-team/capacitor-firebase/tree/main/packages/crashlytics
*/
export class CrashlyticsService extends AsyncServiceBase {
- constructor() {
+ constructor(private deploymentService: DeploymentService) {
super("Crashlytics");
this.registerInitFunction(this.initialise);
}
private async initialise() {
if (Capacitor.isNativePlatform()) {
- const { firebase } = environment.deploymentConfig;
+ const { firebase } = this.deploymentService.config;
// Crashlytics is still supported on native device without firebase config (uses google-services.json)
// so use config property to toggle enabled instead
await this.setEnabled({ enabled: firebase?.crashlytics?.enabled });
diff --git a/src/app/shared/services/db/db-sync.service.ts b/src/app/shared/services/db/db-sync.service.ts
index dd6f3e8e25..ea8ac1b998 100644
--- a/src/app/shared/services/db/db-sync.service.ts
+++ b/src/app/shared/services/db/db-sync.service.ts
@@ -14,6 +14,7 @@ import { AppConfigService } from "../app-config/app-config.service";
import { AsyncServiceBase } from "../asyncService.base";
import { UserMetaService } from "../userMeta/userMeta.service";
import { DbService } from "./db.service";
+import { DeploymentService } from "../deployment/deployment.service";
@Injectable({ providedIn: "root" })
/**
@@ -29,7 +30,8 @@ export class DBSyncService extends AsyncServiceBase {
private dbService: DbService,
private http: HttpClient,
private userMetaService: UserMetaService,
- private appConfigService: AppConfigService
+ private appConfigService: AppConfigService,
+ private deploymentService: DeploymentService
) {
super("DB Sync");
this.registerInitFunction(this.inititialise);
@@ -81,13 +83,14 @@ export class DBSyncService extends AsyncServiceBase {
/** Populate common app_meta to local record */
private generateServerRecord(record: any, mapping: IDBServerMapping) {
+ const { name, _app_builder_version } = this.deploymentService.config;
const { is_user_record, user_record_id_field } = mapping;
if (is_user_record && user_record_id_field) {
const serverRecord: IDBServerUserRecord = {
app_user_id: this.userMetaService.getUserMeta("uuid"),
app_user_record_id: record[user_record_id_field],
- app_deployment_name: environment.deploymentName,
- app_version: environment.version,
+ app_deployment_name: name,
+ app_version: _app_builder_version,
data: record,
};
return serverRecord;
diff --git a/src/app/shared/services/deployment/deployment.service.spec.ts b/src/app/shared/services/deployment/deployment.service.spec.ts
new file mode 100644
index 0000000000..d337640daa
--- /dev/null
+++ b/src/app/shared/services/deployment/deployment.service.spec.ts
@@ -0,0 +1,75 @@
+import { DEPLOYMENT_CONFIG, DeploymentService } from "./deployment.service";
+import { TestBed } from "@angular/core/testing";
+import { DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS, IDeploymentRuntimeConfig } from "packages/data-models";
+
+const mockConfig: IDeploymentRuntimeConfig = {
+ ...DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS,
+ name: "test",
+};
+
+export class MockDeploymentService implements Partial {
+ public readonly config: IDeploymentRuntimeConfig;
+
+ constructor(config: Partial) {
+ this.config = { ...DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS, ...config };
+ }
+ public ready(): boolean {
+ return true;
+ }
+}
+
+/**
+ * Call standalone tests via:
+ * yarn ng test --include src/app/shared/services/deployment/deployment.service.spec.ts
+ */
+describe("Deployment Service", () => {
+ let service: DeploymentService;
+
+ beforeEach(async () => {
+ TestBed.configureTestingModule({
+ providers: [{ provide: DEPLOYMENT_CONFIG, useValue: mockConfig }],
+ });
+ service = TestBed.inject(DeploymentService);
+ });
+
+ it("Loads deployment from injection token", async () => {
+ expect(service.config.name).toEqual(mockConfig.name);
+ });
+});
+
+// LEGACY - should be refactored to test json load during bootstrap
+
+/**
+ *
+// NOTE - prefer use of spy to `HttpTestingController` as allows to specify responses
+// in advance of request (controller must be called after start of init but before complete)
+
+import { asyncData, asyncError } from "src/test/utils";
+
+
+ beforeEach(async () => {
+ // NOTE - prefer use of spy to `HttpTestingController` as allows to specify responses
+ // in advance of request (controller must be called after start of init but before complete)
+ httpClientSpy = jasmine.createSpyObj("HttpClient", ["get"]);
+ service = new DeploymentService(httpClientSpy);
+ });
+
+ it("Loads deployment from assets json", async () => {
+ httpClientSpy.get.and.returnValue(asyncData(mockConfig));
+ await service.ready();
+ expect(service.config().name).toEqual(mockConfig.name);
+ });
+
+ it("Handles missing deployment json", async () => {
+ const errorResponse = new HttpErrorResponse({
+ error: "test 404 error",
+ status: 404,
+ statusText: "Not Found",
+ });
+ httpClientSpy.get.and.returnValue(asyncError(errorResponse));
+ await service.ready();
+ expect(service.config().name).toEqual(DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS.name);
+ // TODO - could also consider check that logger gets called
+ });
+
+ */
diff --git a/src/app/shared/services/deployment/deployment.service.ts b/src/app/shared/services/deployment/deployment.service.ts
new file mode 100644
index 0000000000..e49f9c534d
--- /dev/null
+++ b/src/app/shared/services/deployment/deployment.service.ts
@@ -0,0 +1,33 @@
+import { Inject, Injectable, InjectionToken } from "@angular/core";
+import { IDeploymentRuntimeConfig } from "packages/data-models";
+import { SyncServiceBase } from "../syncService.base";
+
+/**
+ * Token to inject deployment config value into any service.
+ * This is populated from json file before platform load, as part of src\main.ts
+ *
+ * Can be used directly by any service or module initialised at any time
+ * (including app.module.ts).
+ *
+ * @example Inject into service
+ * ```ts
+ * constructor(@Inject(DEPLOYMENT_CONFIG))
+ * ```
+ * @example Inject into module
+ * ```
+ * {provide: MyModule, useFactory:(config)=>{...}, deps: [DEPLOYMENT_CONFIG]`}
+ * ```
+ */
+export const DEPLOYMENT_CONFIG: InjectionToken =
+ new InjectionToken("Application Configuration");
+
+/**
+ * The deployment service provides access to values loaded from the deployment json file
+ * It is an alternative to injecting directly via `@Inject(DEPLOYMENT_CONFIG)`
+ */
+@Injectable({ providedIn: "root" })
+export class DeploymentService extends SyncServiceBase {
+ constructor(@Inject(DEPLOYMENT_CONFIG) public readonly config: IDeploymentRuntimeConfig) {
+ super("Deployment Service");
+ }
+}
diff --git a/src/app/shared/services/dynamic-data/adapters/persistedMemory.ts b/src/app/shared/services/dynamic-data/adapters/persistedMemory.ts
index 73d78a4c2d..4378dd7a81 100644
--- a/src/app/shared/services/dynamic-data/adapters/persistedMemory.ts
+++ b/src/app/shared/services/dynamic-data/adapters/persistedMemory.ts
@@ -17,7 +17,6 @@ addRxPlugin(RxDBUpdatePlugin);
import { debounceTime, filter, firstValueFrom, Subject } from "rxjs";
import { FlowTypes } from "data-models";
-import { environment } from "src/environments/environment";
import { deepMergeObjects, compareObjectKeys } from "../../../utils";
/**
@@ -78,7 +77,7 @@ export class PersistedMemoryAdapter {
[key: string]: RxCollection;
}>;
- constructor() {
+ constructor(private dbName: string) {
this.subscribeToStatePersist();
}
@@ -99,7 +98,7 @@ export class PersistedMemoryAdapter {
public async create() {
this.db = await createRxDatabase({
- name: `${environment.deploymentName}_user`,
+ name: `${this.dbName}_user`,
storage: getRxStorageDexie({ autoOpen: true }),
ignoreDuplicate: true,
});
diff --git a/src/app/shared/services/dynamic-data/adapters/reactiveMemory.ts b/src/app/shared/services/dynamic-data/adapters/reactiveMemory.ts
index 1d788dd47d..59d8f62882 100644
--- a/src/app/shared/services/dynamic-data/adapters/reactiveMemory.ts
+++ b/src/app/shared/services/dynamic-data/adapters/reactiveMemory.ts
@@ -22,8 +22,6 @@ import { RxDBUpdatePlugin } from "rxdb/plugins/update";
addRxPlugin(RxDBUpdatePlugin);
import { BehaviorSubject } from "rxjs";
-import { environment } from "src/environments/environment";
-
/**
* Create a base schema for data
* NOTE - by default assumes data has an id field which will be used as primary key
@@ -59,7 +57,7 @@ interface IDataUpdate {
data?: Record;
}
-export class ReactiveMemoryAdapater {
+export class ReactiveMemoryAdapter {
private db: RxDatabase<
{
[key: string]: RxCollection;
@@ -67,10 +65,11 @@ export class ReactiveMemoryAdapater {
MemoryStorageInternals,
RxStorageMemoryInstanceCreationOptions
>;
+ constructor(private dbName: string) {}
public async createDB() {
this.db = await createRxDatabase({
- name: `${environment.deploymentName}`,
+ name: this.dbName,
storage: getRxStorageMemory(),
ignoreDuplicate: true,
});
diff --git a/src/app/shared/services/dynamic-data/dynamic-data.service.ts b/src/app/shared/services/dynamic-data/dynamic-data.service.ts
index a63635ee57..356ccfd645 100644
--- a/src/app/shared/services/dynamic-data/dynamic-data.service.ts
+++ b/src/app/shared/services/dynamic-data/dynamic-data.service.ts
@@ -8,9 +8,10 @@ import { AppDataService } from "../data/app-data.service";
import { AsyncServiceBase } from "../asyncService.base";
import { arrayToHashmap, deepMergeObjects } from "../../utils";
import { PersistedMemoryAdapter } from "./adapters/persistedMemory";
-import { ReactiveMemoryAdapater, REACTIVE_SCHEMA_BASE } from "./adapters/reactiveMemory";
+import { ReactiveMemoryAdapter, REACTIVE_SCHEMA_BASE } from "./adapters/reactiveMemory";
import { TemplateActionRegistry } from "../../components/template/services/instance/template-action.registry";
import { TopLevelProperty } from "rxdb/dist/types/types";
+import { DeploymentService } from "../deployment/deployment.service";
type IDocWithMeta = { id: string; APP_META?: Record };
@@ -27,7 +28,7 @@ export class DynamicDataService extends AsyncServiceBase {
* Each flow is represented in its own collection, and populated as requested.
* This allows users to query and subscribe to data changes in an efficient way
*/
- private db: ReactiveMemoryAdapater;
+ private db: ReactiveMemoryAdapter;
/**
* A separate cache stores user edits flow data, initially in memory
@@ -46,7 +47,8 @@ export class DynamicDataService extends AsyncServiceBase {
constructor(
private appDataService: AppDataService,
- private templateActionRegistry: TemplateActionRegistry
+ private templateActionRegistry: TemplateActionRegistry,
+ private deploymentService: DeploymentService
) {
super("Dynamic Data");
this.registerInitFunction(this.initialise);
@@ -54,6 +56,10 @@ export class DynamicDataService extends AsyncServiceBase {
}
private async initialise() {
+ // Use the deployment name as unique database identifier
+ // This will allow multiple databases to be used on the same origin
+ // for different deployments (e.g. dev sites running on localhost)
+ const { name } = this.deploymentService.config;
// Enable dev mode when not in production
// NOTE - calls 'global' so requires polyfill
if (!environment.production) {
@@ -61,8 +67,8 @@ export class DynamicDataService extends AsyncServiceBase {
addRxPlugin(module.RxDBDevModePlugin);
});
}
- this.writeCache = await new PersistedMemoryAdapter().create();
- this.db = await new ReactiveMemoryAdapater().createDB();
+ this.writeCache = await new PersistedMemoryAdapter(name).create();
+ this.db = await new ReactiveMemoryAdapter(name).createDB();
}
private registerTemplateActionHandlers() {
this.templateActionRegistry.register({
@@ -118,8 +124,8 @@ export class DynamicDataService extends AsyncServiceBase {
}
/** Take a snapshot of the current state of a table */
- public async snapshot(flow_type: FlowTypes.FlowType, flow_name: string) {
- const obs = await this.query$(flow_type, flow_name);
+ public async snapshot(flow_type: FlowTypes.FlowType, flow_name: string) {
+ const obs = await this.query$(flow_type, flow_name);
return firstValueFrom(obs);
}
diff --git a/src/app/shared/services/error-handler/error-handler.service.ts b/src/app/shared/services/error-handler/error-handler.service.ts
index 5c7b55adce..45e557fe0a 100644
--- a/src/app/shared/services/error-handler/error-handler.service.ts
+++ b/src/app/shared/services/error-handler/error-handler.service.ts
@@ -7,6 +7,7 @@ import { GIT_SHA } from "src/environments/sha";
import { fromError as getStacktraceFromError } from "stacktrace-js";
import { CrashlyticsService } from "../crashlytics/crashlytics.service";
import { FirebaseService } from "../firebase/firebase.service";
+import { DeploymentService } from "../deployment/deployment.service";
@Injectable({
providedIn: "root",
@@ -18,7 +19,11 @@ export class ErrorHandlerService extends ErrorHandler {
// Error handling is important and needs to be loaded first.
// Because of this we should manually inject the services with Injector.
- constructor(private injector: Injector, private firebaseService: FirebaseService) {
+ constructor(
+ private injector: Injector,
+ private firebaseService: FirebaseService,
+ private deploymentService: DeploymentService
+ ) {
super();
}
@@ -30,13 +35,12 @@ export class ErrorHandlerService extends ErrorHandler {
* (although workaround required as cannot extend multiple services)
*/
private async initialise() {
- const { production, deploymentConfig } = environment;
- const { error_logging, firebase } = deploymentConfig;
- if (production && error_logging?.dsn) {
+ const { error_logging, firebase } = this.deploymentService.config;
+ if (environment.production && error_logging?.dsn) {
await this.initialiseSentry();
this.sentryEnabled = true;
}
- if (production && this.firebaseService.app && Capacitor.isNativePlatform()) {
+ if (environment.production && this.firebaseService.app && Capacitor.isNativePlatform()) {
// crashlytics initialised in app component so omitted here
this.crashlyticsEnabled = firebase.crashlytics.enabled;
}
@@ -74,12 +78,11 @@ export class ErrorHandlerService extends ErrorHandler {
* https://docs.sentry.io/platforms/javascript/guides/capacitor/
*/
private async initialiseSentry() {
- const { deploymentConfig, version, production } = environment;
- const { error_logging, name } = deploymentConfig;
+ const { error_logging, name, _app_builder_version } = this.deploymentService.config;
Sentry.init({
dsn: error_logging?.dsn,
- environment: production ? "production" : "development",
- release: `${name}-${version}-${GIT_SHA}`,
+ environment: environment.production ? "production" : "development",
+ release: `${name}-${_app_builder_version}-${GIT_SHA}`,
autoSessionTracking: false,
attachStacktrace: true,
enabled: true,
diff --git a/src/app/shared/services/file-manager/file-manager.service.ts b/src/app/shared/services/file-manager/file-manager.service.ts
index 6e59d18e0a..be0bf3c48e 100644
--- a/src/app/shared/services/file-manager/file-manager.service.ts
+++ b/src/app/shared/services/file-manager/file-manager.service.ts
@@ -5,10 +5,10 @@ import { Capacitor } from "@capacitor/core";
import write_blob from "capacitor-blob-writer";
import { saveAs } from "file-saver";
import { SyncServiceBase } from "../syncService.base";
-import { environment } from "src/environments/environment";
import { TemplateActionRegistry } from "../../components/template/services/instance/template-action.registry";
import { TemplateAssetService } from "../../components/template/services/template-asset.service";
import { ErrorHandlerService } from "../error-handler/error-handler.service";
+import { DeploymentService } from "../deployment/deployment.service";
@Injectable({
providedIn: "root",
@@ -19,14 +19,15 @@ export class FileManagerService extends SyncServiceBase {
constructor(
private errorHandler: ErrorHandlerService,
private templateActionRegistry: TemplateActionRegistry,
- private templateAssetService: TemplateAssetService
+ private templateAssetService: TemplateAssetService,
+ private deploymentService: DeploymentService
) {
super("FileManager");
this.initialise();
}
private initialise() {
- this.cacheName = environment.deploymentConfig.name;
+ this.cacheName = this.deploymentService.config.name;
this.registerTemplateActionHandlers();
}
diff --git a/src/app/shared/services/firebase/firebase.service.ts b/src/app/shared/services/firebase/firebase.service.ts
index e17f6097c1..e8ca57e734 100644
--- a/src/app/shared/services/firebase/firebase.service.ts
+++ b/src/app/shared/services/firebase/firebase.service.ts
@@ -1,7 +1,7 @@
import { Injectable } from "@angular/core";
import { initializeApp, FirebaseApp } from "firebase/app";
-import { environment } from "src/environments/environment";
import { SyncServiceBase } from "../syncService.base";
+import { DeploymentService } from "../deployment/deployment.service";
/** Service used to configure initialize firebase app core configuration */
@Injectable({ providedIn: "root" })
@@ -9,7 +9,7 @@ export class FirebaseService extends SyncServiceBase {
/** Initialised firebase app. Will be undefined if firebase config unavailable */
app: FirebaseApp | undefined;
- constructor() {
+ constructor(private deploymentService: DeploymentService) {
super("Firebase");
this.initialise();
}
@@ -18,7 +18,7 @@ export class FirebaseService extends SyncServiceBase {
* Configure app module imports dependent on what firebase features should be enabled
*/
private initialise() {
- const { firebase } = environment.deploymentConfig;
+ const { firebase } = this.deploymentService.config;
// Check if any services are enabled, simply return if not
const enabledServices = Object.entries(firebase)
@@ -32,6 +32,6 @@ export class FirebaseService extends SyncServiceBase {
return;
}
- this.app = initializeApp(environment.deploymentConfig.firebase.config);
+ this.app = initializeApp(firebase.config);
}
}
diff --git a/src/app/shared/services/local-storage/local-storage.service.spec.ts b/src/app/shared/services/local-storage/local-storage.service.spec.ts
index d3fe77245e..f1a80fb262 100644
--- a/src/app/shared/services/local-storage/local-storage.service.spec.ts
+++ b/src/app/shared/services/local-storage/local-storage.service.spec.ts
@@ -1,6 +1,7 @@
import { TestBed } from "@angular/core/testing";
import { LocalStorageService } from "./local-storage.service";
+import { IProtectedFieldName } from "packages/data-models";
/** Mock calls to localstorage to store values in-memory */
export class MockLocalStorageService implements Partial {
@@ -14,6 +15,12 @@ export class MockLocalStorageService implements Partial {
public ready(): boolean {
return true;
}
+ public getProtected(field: IProtectedFieldName): string {
+ return this.getString(`_${field}`);
+ }
+ public setProtected(field: IProtectedFieldName, value: string) {
+ return this.setString(`_${field}`, value);
+ }
}
/**
diff --git a/src/app/shared/services/remote-asset/remote-asset.service.ts b/src/app/shared/services/remote-asset/remote-asset.service.ts
index 5cfd63abc6..41031f2bf7 100644
--- a/src/app/shared/services/remote-asset/remote-asset.service.ts
+++ b/src/app/shared/services/remote-asset/remote-asset.service.ts
@@ -3,7 +3,6 @@ import { HttpClient, HttpEventType } from "@angular/common/http";
import { Capacitor } from "@capacitor/core";
import { createClient, SupabaseClient } from "@supabase/supabase-js";
import { FileObject } from "@supabase/storage-js";
-import { environment } from "src/environments/environment";
import { TemplateActionRegistry } from "../../components/template/services/instance/template-action.registry";
import { FlowTypes, IAppConfig } from "../../model";
import { AppConfigService } from "../app-config/app-config.service";
@@ -16,6 +15,7 @@ import { AsyncServiceBase } from "../asyncService.base";
import { IAssetEntry, IAssetOverrideProps } from "packages/data-models/deployment.model";
import { DynamicDataService } from "../dynamic-data/dynamic-data.service";
import { arrayToHashmap, convertBlobToBase64, deepMergeObjects } from "../../utils";
+import { DeploymentService } from "../deployment/deployment.service";
const CORE_ASSET_PACK_NAME = "core_assets";
@@ -39,7 +39,8 @@ export class RemoteAssetService extends AsyncServiceBase {
private fileManagerService: FileManagerService,
private templateAssetService: TemplateAssetService,
private templateActionRegistry: TemplateActionRegistry,
- private http: HttpClient
+ private http: HttpClient,
+ private deploymentService: DeploymentService
) {
super("RemoteAsset");
this.registerInitFunction(this.initialise);
@@ -48,7 +49,7 @@ export class RemoteAssetService extends AsyncServiceBase {
private async initialise() {
this.registerTemplateActionHandlers();
// require supabase to be configured to use remote asset service
- const { enabled, publicApiKey, url } = environment.deploymentConfig.supabase;
+ const { enabled, publicApiKey, url } = this.deploymentService.config.supabase;
this.supabaseEnabled = enabled;
if (this.supabaseEnabled) {
await this.ensureAsyncServicesReady([this.templateAssetService, this.dynamicDataService]);
diff --git a/src/app/shared/services/seo/seo.service.ts b/src/app/shared/services/seo/seo.service.ts
index 61a20b9072..6e9b254d15 100644
--- a/src/app/shared/services/seo/seo.service.ts
+++ b/src/app/shared/services/seo/seo.service.ts
@@ -1,6 +1,6 @@
import { Injectable } from "@angular/core";
-import { environment } from "src/environments/environment";
import { SyncServiceBase } from "../syncService.base";
+import { DeploymentService } from "../deployment/deployment.service";
interface ISEOMeta {
title: string;
@@ -21,7 +21,7 @@ type IMetaName =
providedIn: "root",
})
export class SeoService extends SyncServiceBase {
- constructor() {
+ constructor(private deploymentService: DeploymentService) {
super("SEO Service");
// call after init to apply defaults
this.updateMeta({});
@@ -65,7 +65,7 @@ export class SeoService extends SyncServiceBase {
private getDefaultSEOTags(): ISEOMeta {
const PUBLIC_URL = location.origin;
let faviconUrl = `${PUBLIC_URL}/assets/icon/favicon.svg`;
- const { web, app_config } = environment.deploymentConfig;
+ const { web, app_config } = this.deploymentService.config;
if (web?.favicon_asset) {
faviconUrl = `${PUBLIC_URL}/assets/app_data/assets/${web.favicon_asset}`;
}
diff --git a/src/app/shared/services/server/interceptors.ts b/src/app/shared/services/server/interceptors.ts
index 85e5119a7d..954d89cbf6 100644
--- a/src/app/shared/services/server/interceptors.ts
+++ b/src/app/shared/services/server/interceptors.ts
@@ -1,31 +1,38 @@
-import { Injectable } from "@angular/core";
+import { Inject, Injectable } from "@angular/core";
import {
HttpEvent,
HttpInterceptor,
HttpHandler,
HttpRequest,
- HTTP_INTERCEPTORS,
HttpHeaders,
} from "@angular/common/http";
-import { environment } from "src/environments/environment";
import { Observable } from "rxjs";
-
-let { db_name, endpoint: API_ENDPOINT } = environment.deploymentConfig.api;
-
-// Override development credentials when running locally
-if (!environment.production) {
- // Docker endpoint. Replace :3000 with /api if running standalone api
- API_ENDPOINT = "http://localhost:3000";
- db_name = "dev";
-}
+import { DEPLOYMENT_CONFIG } from "../deployment/deployment.service";
+import { IDeploymentRuntimeConfig } from "packages/data-models";
/** Handle updating urls intended for api server */
@Injectable()
export class ServerAPIInterceptor implements HttpInterceptor {
+ // Inject the global deployment config to use with requests
+ constructor(@Inject(DEPLOYMENT_CONFIG) private deploymentConfig: IDeploymentRuntimeConfig) {}
+
+ /**
+ * Intercept all http requests to rewrite including database api endpoint and
+ * deployment-db-name headers, as read from deployment config
+ */
intercept(req: HttpRequest, next: HttpHandler): Observable> {
// assume requests targetting / (e.g. /app_users) is directed to api endpoint
if (req.url.startsWith("/")) {
- const replacedUrl = `${API_ENDPOINT}${req.url}`;
+ const { db_name, endpoint, enabled } = this.deploymentConfig.api;
+ // If not using api silently cancel any requests to the api
+ // TODO - better to disable in service (could also replace interceptor with service more generally)
+ if (!enabled) return;
+ if (!db_name || !endpoint) {
+ console.warn("api endpoint not configured, ignoring request", req.url);
+ return;
+ }
+
+ const replacedUrl = `${endpoint}${req.url}`;
// append deployment-specific values (header set/append methods inconsistent so create new)
const headerValues = { "x-deployment-db-name": db_name };
for (const key of req.headers.keys()) {
@@ -37,8 +44,3 @@ export class ServerAPIInterceptor implements HttpInterceptor {
return next.handle(req);
}
}
-
-/** Http interceptor providers in outside-in order */
-export const httpInterceptorProviders = [
- { provide: HTTP_INTERCEPTORS, useClass: ServerAPIInterceptor, multi: true },
-];
diff --git a/src/app/shared/services/server/server.service.ts b/src/app/shared/services/server/server.service.ts
index 0edd998021..fc0c04a6d7 100644
--- a/src/app/shared/services/server/server.service.ts
+++ b/src/app/shared/services/server/server.service.ts
@@ -10,6 +10,7 @@ import { AppConfigService } from "../app-config/app-config.service";
import { SyncServiceBase } from "../syncService.base";
import { LocalStorageService } from "../local-storage/local-storage.service";
import { DynamicDataService } from "../dynamic-data/dynamic-data.service";
+import { DeploymentService } from "../deployment/deployment.service";
/**
* Backend API
@@ -31,7 +32,8 @@ export class ServerService extends SyncServiceBase {
private http: HttpClient,
private appConfigService: AppConfigService,
private localStorageService: LocalStorageService,
- private dynamicDataService: DynamicDataService
+ private dynamicDataService: DynamicDataService,
+ private deploymentService: DeploymentService
) {
super("Server");
this.initialise();
@@ -57,6 +59,7 @@ export class ServerService extends SyncServiceBase {
}
public async syncUserData() {
+ const { name, _app_builder_version } = this.deploymentService.config;
await this.dynamicDataService.ready();
if (!this.device_info) {
this.device_info = await Device.getInfo();
@@ -76,9 +79,9 @@ export class ServerService extends SyncServiceBase {
// TODO - get DTO from api (?)
const data = {
contact_fields,
- app_version: environment.version,
+ app_version: _app_builder_version,
device_info: this.device_info,
- app_deployment_name: environment.deploymentName,
+ app_deployment_name: name,
dynamic_data,
};
console.log("[SERVER] sync data", data);
diff --git a/src/app/shared/services/skin/skin.service.spec.ts b/src/app/shared/services/skin/skin.service.spec.ts
index 0479c4c2aa..91163628c5 100644
--- a/src/app/shared/services/skin/skin.service.spec.ts
+++ b/src/app/shared/services/skin/skin.service.spec.ts
@@ -1,16 +1,157 @@
import { TestBed } from "@angular/core/testing";
import { SkinService } from "./skin.service";
+import { LocalStorageService } from "../local-storage/local-storage.service";
+import { MockLocalStorageService } from "../local-storage/local-storage.service.spec";
+import { AppConfigService } from "../app-config/app-config.service";
+import { MockAppConfigService } from "../app-config/app-config.service.spec";
+import { TemplateService } from "../../components/template/services/template.service";
+import { ThemeService } from "src/app/feature/theme/services/theme.service";
+import { MockThemeService } from "src/app/feature/theme/services/theme.service.spec";
+import { IAppConfig, IAppSkin } from "packages/data-models";
+import { deepMergeObjects } from "../../utils";
+import clone from "clone";
+class MockTemplateService implements Partial {
+ ready() {
+ return true;
+ }
+ async initialiseDefaultFieldAndGlobals() {
+ return;
+ }
+}
+
+const MOCK_SKIN_1: IAppSkin = {
+ name: "MOCK_SKIN_1",
+ appConfig: { APP_HEADER_DEFAULTS: { title: "mock 1", colour: "primary" } },
+};
+const MOCK_SKIN_2: IAppSkin = {
+ name: "MOCK_SKIN_2",
+ appConfig: { APP_HEADER_DEFAULTS: { title: "mock 2", variant: "compact" } },
+};
+
+const MOCK_APP_CONFIG: Partial = {
+ APP_HEADER_DEFAULTS: {
+ title: "default",
+ collapse: false,
+ colour: "none",
+ should_minimize_app_on_back: () => true,
+ should_show_back_button: () => true,
+ should_show_menu_button: () => true,
+ variant: "default",
+ },
+ APP_SKINS: {
+ available: [MOCK_SKIN_1, MOCK_SKIN_2],
+ defaultSkinName: "MOCK_SKIN_1",
+ },
+ APP_THEMES: {
+ available: ["MOCK_THEME_1", "MOCK_THEME_2"],
+ defaultThemeName: "MOCK_THEME_1",
+ },
+ APP_FOOTER_DEFAULTS: {
+ templateName: "mock_footer",
+ },
+};
+
+/**
+ * Call standalone tests via:
+ * yarn ng test --include src/app/shared/services/skin/skin.service.spec.ts
+ */
describe("SkinService", () => {
let service: SkinService;
beforeEach(() => {
- TestBed.configureTestingModule({});
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: LocalStorageService, useValue: new MockLocalStorageService() },
+ {
+ provide: AppConfigService,
+ useValue: new MockAppConfigService(MOCK_APP_CONFIG),
+ },
+ { provide: TemplateService, useValue: new MockTemplateService() },
+ // TODO - create better mock and test methods
+ { provide: ThemeService, useValue: new MockThemeService() },
+ ],
+ });
service = TestBed.inject(SkinService);
});
- it("should be created", () => {
- expect(service).toBeTruthy();
+ it("creates hashmap of available skins on init", () => {
+ const skins = service["availableSkins"];
+ expect(skins).toEqual({ MOCK_SKIN_1, MOCK_SKIN_2 });
+ });
+
+ it("loads default skin on init", () => {
+ expect(service.getActiveSkinName()).toEqual("MOCK_SKIN_1");
+ });
+
+ it("does not change non-overridden values", () => {
+ expect(service["appConfigService"].appConfig().APP_FOOTER_DEFAULTS).toEqual({
+ templateName: "mock_footer",
+ });
+ });
+
+ it("loads active skin from local storage on init if available", () => {
+ service["localStorageService"].setProtected("APP_SKIN", "MOCK_SKIN_2");
+ expect(service.getActiveSkinName()).toEqual("MOCK_SKIN_2");
+ });
+
+ it("generates override and revert configs", () => {
+ expect(service["revertOverride"]).toEqual({
+ APP_HEADER_DEFAULTS: { title: "default", colour: "none" },
+ });
});
+
+ it("reverts previous override when applying another skin", () => {
+ // MOCK_SKIN_1 will already be applied on load
+ const override = service["generateOverrideConfig"](MOCK_SKIN_2);
+ // creates a deep merge of override properties on top of current
+ expect(override).toEqual({
+ APP_HEADER_DEFAULTS: {
+ // revert changes only available in skin_1
+ colour: "none",
+ // apply changes from skin_2
+ title: "mock 2",
+ variant: "compact",
+ },
+ });
+ const revert = service["generateRevertConfig"](MOCK_SKIN_2);
+
+ // creates config revert to undo just the skin changes
+ expect(revert).toEqual({
+ APP_HEADER_DEFAULTS: {
+ // only revert changes remaining from skin_2
+ title: "default",
+ variant: "default",
+ },
+ });
+ });
+
+ it("sets skin: sets active skin name", () => {
+ service["setSkin"](MOCK_SKIN_2.name);
+ expect(service.getActiveSkinName()).toEqual("MOCK_SKIN_2");
+ service["setSkin"](MOCK_SKIN_1.name);
+ expect(service.getActiveSkinName()).toEqual("MOCK_SKIN_1");
+ });
+
+ it("sets skin: sets revertOverride correctly", () => {
+ // MOCK_SKIN_1 will already be applied on load
+ service["setSkin"](MOCK_SKIN_2.name);
+ expect(service["revertOverride"]).toEqual({
+ APP_HEADER_DEFAULTS: {
+ title: "default",
+ variant: "default",
+ },
+ });
+ });
+
+ it("sets skin: updates AppConfigService.appConfig values", () => {
+ // MOCK_SKIN_1 will already be applied on load
+ service["setSkin"](MOCK_SKIN_2.name);
+ expect(service["appConfigService"].appConfig() as Partial).toEqual(
+ deepMergeObjects(clone(MOCK_APP_CONFIG), clone(MOCK_SKIN_2).appConfig)
+ );
+ });
+
+ // TODO - add further tests for setSkin method and side-effects
});
diff --git a/src/app/shared/services/skin/skin.service.ts b/src/app/shared/services/skin/skin.service.ts
index 443bb87ee4..3cea4f82d2 100644
--- a/src/app/shared/services/skin/skin.service.ts
+++ b/src/app/shared/services/skin/skin.service.ts
@@ -1,12 +1,10 @@
import { Injectable } from "@angular/core";
-import { BehaviorSubject } from "rxjs";
import { LocalStorageService } from "src/app/shared/services/local-storage/local-storage.service";
import { IAppConfig, IAppSkin } from "data-models";
-import { arrayToHashmap } from "../../utils";
+import { updatedDiff } from "deep-object-diff";
+import { arrayToHashmap, deepMergeObjects, RecursivePartial } from "../../utils";
import { AppConfigService } from "../app-config/app-config.service";
import { TemplateService } from "../../components/template/services/template.service";
-import { Router } from "@angular/router";
-import { APP_CONFIG } from "src/app/data";
import { ThemeService } from "src/app/feature/theme/services/theme.service";
import { SyncServiceBase } from "../syncService.base";
@@ -14,18 +12,17 @@ import { SyncServiceBase } from "../syncService.base";
providedIn: "root",
})
export class SkinService extends SyncServiceBase {
- // A hashmap of all skins available to the current deployment
+ /** A hashmap of all skins available to the current deployment */
private availableSkins: Record;
- private activeSkin$ = new BehaviorSubject(undefined);
- private appConfig: IAppConfig;
- private skinsConfig: IAppConfig["APP_SKINS"];
+
+ /** Track overrides required to undo a previously applied skin (if applying another) */
+ private revertOverride: RecursivePartial = {};
constructor(
- private localStorageService: LocalStorageService,
private appConfigService: AppConfigService,
+ private localStorageService: LocalStorageService,
private templateService: TemplateService,
- private themeService: ThemeService,
- private router: Router
+ private themeService: ThemeService
) {
super("Skin Service");
this.initialise();
@@ -37,17 +34,8 @@ export class SkinService extends SyncServiceBase {
this.appConfigService,
this.themeService,
this.templateService,
- this.appConfigService,
]);
- this.subscribeToAppConfigChanges();
- // Retrieve the last active skin and apply it. Fallback on deployment's default skin
- // if there is no last active skin, or if it is not "available" in current appConfig
- const lastActiveSkinName = this.getActiveSkinName();
- let targetSkinName = this.skinsConfig.defaultSkinName;
- if (lastActiveSkinName && this.availableSkins.hasOwnProperty(lastActiveSkinName)) {
- targetSkinName = lastActiveSkinName;
- }
- this.setSkin(targetSkinName, true);
+ this.loadActiveSkin();
}
/**
@@ -56,13 +44,15 @@ export class SkinService extends SyncServiceBase {
* @param [isInit=false] Whether or not the function is being triggered by the service's initialisation
* */
public setSkin(skinName: string, isInit = false) {
- if (skinName in this.availableSkins) {
- const oldSkin = this.activeSkin$.value;
+ if (this.availableSkins.hasOwnProperty(skinName)) {
const targetSkin = this.availableSkins[skinName];
- // console.log("[SET SKIN]", skinName, targetSkin);
- this.activeSkin$.next(targetSkin);
- // Update appConfig to reflect any overrides defined by the skin
- this.appConfigService.updateAppConfig(targetSkin.appConfig);
+
+ const override = this.generateOverrideConfig(targetSkin);
+ const revert = this.generateRevertConfig(targetSkin);
+ console.log("[SKIN] SET", { targetSkin, override, revert });
+ this.appConfigService.setAppConfig(override);
+ this.revertOverride = revert;
+
if (!isInit) {
// Update default values when skin changed to allow for skin-specific global overrides
// Don't run on initialisation, since the skin and appConfig services must init before the template service and its dependencies
@@ -72,7 +62,6 @@ export class SkinService extends SyncServiceBase {
}
// Use local storage so that the active skin persists across app launches
this.localStorageService.setProtected("APP_SKIN", targetSkin.name);
- this.updateRoutingDefaults(targetSkin, oldSkin);
} else {
console.error(`No skin found with name "${skinName}"`, {
availableSkins: this.availableSkins,
@@ -80,82 +69,65 @@ export class SkinService extends SyncServiceBase {
}
}
- /** Override changes to config-dependent routing config inherited in app-routing.module */
- private updateRoutingDefaults(newSkin?: IAppSkin, oldSkin?: IAppSkin) {
- const newRouteDefaults = newSkin?.appConfig?.APP_ROUTE_DEFAULTS;
- let routes = this.router.config;
- if (newRouteDefaults) {
- const { APP_ROUTE_DEFAULTS: oldRouteDefaults } = oldSkin?.appConfig || APP_CONFIG;
- // Replace default home route
- // { path: "", redirectTo: APP_ROUTE_DEFAULTS.home_route, pathMatch: "full" },
- if (
- newRouteDefaults.home_route &&
- newRouteDefaults.home_route !== oldRouteDefaults.home_route
- ) {
- const homeRouteIndex = routes.findIndex((route) => route.path === "");
- if (homeRouteIndex > -1) {
- routes[homeRouteIndex].redirectTo = newRouteDefaults.home_route;
- }
- }
- // Replace fallbackRoute
- // { path: "**", redirectTo: APP_ROUTE_DEFAULTS.fallback_route };
- if (
- newRouteDefaults.fallback_route &&
- newRouteDefaults.fallback_route !== oldRouteDefaults.fallback_route
- ) {
- const fallbackRouteIndex = routes.findIndex((route) => route.path === "**");
- if (fallbackRouteIndex > -1) {
- routes[fallbackRouteIndex].redirectTo = newRouteDefaults.fallback_route;
- }
- }
- if (newRouteDefaults.redirects) {
- // Remove old redirects
- if (oldRouteDefaults.redirects) {
- const redirectedPaths = oldRouteDefaults.redirects.map((route) => route.path);
- routes = routes.filter((route) => !redirectedPaths.includes(route.path));
- }
- // Add new redirects
- for (const { path, redirectTo } of newRouteDefaults.redirects) {
- routes.push({ path, redirectTo });
- }
- }
- this.router.resetConfig(routes);
- }
- }
-
/** Get the name of the active skin, as saved in local storage */
public getActiveSkinName() {
return this.localStorageService.getProtected("APP_SKIN");
}
- /** Get the full active skin, from the skin name saved in local storage */
- public getActiveSkin() {
- const activeSkinName = this.getActiveSkin();
- return this.availableSkins[activeSkinName];
+ /**
+ * Skin overrides are designed to be merged on top of the default app config
+ * When applying a new skin calculate the config changes required to both
+ * revert any previous skin override and apply new
+ */
+ private generateOverrideConfig(skin: IAppSkin) {
+ // Merge onto new object to avoid changing stored revertOverride
+ const base: RecursivePartial = {};
+ return deepMergeObjects(base, this.revertOverride, skin.appConfig);
+ }
+
+ /** Determine config that would need to be applied to revert the new update */
+ private generateRevertConfig(skin: IAppSkin) {
+ const revert: RecursivePartial = {};
+ const config = this.appConfigService.appConfig();
+ for (const key of Object.keys(skin.appConfig || {})) {
+ // When reverting the skin, should target the current config value unless
+ // previously overridden (in which case target initial value)
+ const revertTarget = deepMergeObjects({}, config[key], this.revertOverride[key]);
+ // Track what has changed to be able to revert back in future
+ revert[key] = updatedDiff(skin.appConfig[key], revertTarget);
+ }
+ return revert;
+ }
+
+ /**
+ * Load the active app skin. Loads previously stored configuration if available,
+ * with fallback to default app skin
+ */
+ private loadActiveSkin() {
+ const { available, defaultSkinName } = this.appConfigService.appConfig().APP_SKINS;
+ this.availableSkins = arrayToHashmap(available, "name");
+ const activeSkinName = this.getActiveSkinName();
+ if (activeSkinName && this.availableSkins.hasOwnProperty(activeSkinName)) {
+ this.setSkin(activeSkinName, true);
+ } else {
+ this.setSkin(defaultSkinName, true);
+ }
}
private applySkinThemeChanges() {
- const targetSkinDefaultTheme = this.appConfig.APP_THEMES.defaultThemeName;
- if (targetSkinDefaultTheme) {
- this.themeService.setTheme(targetSkinDefaultTheme);
+ const { available, defaultThemeName } = this.appConfigService.appConfig().APP_THEMES;
+ if (defaultThemeName) {
+ this.themeService.setTheme(defaultThemeName);
}
// If target skin has no default theme and the current theme is not available in the target skin,
// then set theme to the first available theme of the target skin
else if (!this.isCurrentThemeAvailableInTargetSkin()) {
- this.themeService.setTheme(this.appConfig.APP_THEMES.available[0]);
+ this.themeService.setTheme(available[0]);
}
}
private isCurrentThemeAvailableInTargetSkin() {
const currentTheme = this.themeService.getCurrentTheme();
- return this.appConfig.APP_THEMES.available.includes(currentTheme);
- }
-
- subscribeToAppConfigChanges() {
- this.appConfigService.appConfig$.subscribe((appConfig: IAppConfig) => {
- this.appConfig = appConfig;
- this.skinsConfig = this.appConfig.APP_SKINS;
- this.availableSkins = arrayToHashmap(this.skinsConfig.available, "name");
- });
+ return this.appConfigService.appConfig().APP_THEMES.available.includes(currentTheme);
}
}
diff --git a/src/app/shared/services/task/task-action.service.ts b/src/app/shared/services/task/task-action.service.ts
index a80da37711..5cec8a66ed 100644
--- a/src/app/shared/services/task/task-action.service.ts
+++ b/src/app/shared/services/task/task-action.service.ts
@@ -1,10 +1,10 @@
import { Injectable } from "@angular/core";
import { Subject } from "rxjs";
-import { environment } from "src/environments/environment";
import { generateTimestamp } from "../../utils";
import { AsyncServiceBase } from "../asyncService.base";
import { DbService } from "../db/db.service";
+import { DeploymentService } from "../deployment/deployment.service";
@Injectable({ providedIn: "root" })
/**
@@ -25,7 +25,10 @@ export class TaskActionService extends AsyncServiceBase {
private appInactiveStartTime = new Date().getTime();
/** Don't log inactivity periods lower than this number (30000ms = 30s) */
private readonly INACTIVITY_THRESHOLD = 30000;
- constructor(private db: DbService) {
+ constructor(
+ private db: DbService,
+ private deploymentService: DeploymentService
+ ) {
super("TaskActions");
this.registerInitFunction(this.initialise);
}
@@ -130,13 +133,14 @@ export class TaskActionService extends AsyncServiceBase {
}
private createNewEntry(task_id: string) {
+ const { _app_builder_version } = this.deploymentService.config;
const timestamp = generateTimestamp();
const entry: ITaskEntry = {
id: `${task_id}_${timestamp}`,
task_id,
actions: [],
_created: timestamp,
- _appVersion: environment.version,
+ _appVersion: _app_builder_version,
_completed: false,
_duration: 0,
};
diff --git a/src/app/shared/services/task/task.service.spec.ts b/src/app/shared/services/task/task.service.spec.ts
index 8d51cc23a8..e883e750ee 100644
--- a/src/app/shared/services/task/task.service.spec.ts
+++ b/src/app/shared/services/task/task.service.spec.ts
@@ -73,6 +73,8 @@ let mockTemplateFieldService: MockTemplateFieldService;
describe("TaskService", () => {
let service: TaskService;
let scheduleCampaignNotificationsSpy: jasmine.Spy;
+ let fetchTaskRowSpy: jasmine.Spy;
+ let fetchTaskRowsSpy: jasmine.Spy;
beforeEach(async () => {
scheduleCampaignNotificationsSpy = jasmine.createSpy();
@@ -107,6 +109,30 @@ describe("TaskService", () => {
],
});
service = TestBed.inject(TaskService);
+
+ fetchTaskRowSpy = spyOn(service, "fetchTaskRow").and.callFake(
+ (dataListName, rowId) => {
+ if (rowId === "validRowId") {
+ return Promise.resolve({
+ completed: false,
+ task_child: "childDataList",
+ completed_field: "completed_field",
+ });
+ }
+ return Promise.resolve(null);
+ }
+ );
+
+ fetchTaskRowsSpy = spyOn(service, "fetchTaskRows").and.callFake(
+ (dataListName) => {
+ if (dataListName === "childDataList") {
+ return Promise.resolve([{ completed: true }, { completed: true }]);
+ }
+ return Promise.resolve([]);
+ }
+ );
+
+ spyOn(service, "setTaskCompletion").and.resolveTo(true);
});
it("should be created", () => {
@@ -134,10 +160,10 @@ describe("TaskService", () => {
});
it("evaluates highlighted task group correctly after init", async () => {
await service.ready();
- expect(service.evaluateHighlightedTaskGroup().previousHighlightedTaskGroup).toBe(
+ expect(service["evaluateHighlightedTaskGroup"]().previousHighlightedTaskGroup).toBe(
MOCK_DATA.data_list[taskGroupsListName].rows[0].id
);
- expect(service.evaluateHighlightedTaskGroup().newHighlightedTaskGroup).toBe(
+ expect(service["evaluateHighlightedTaskGroup"]().newHighlightedTaskGroup).toBe(
MOCK_DATA.data_list[taskGroupsListName].rows[0].id
);
});
@@ -152,7 +178,7 @@ describe("TaskService", () => {
});
it("can set a task group's completed status", async () => {
await service.ready();
- await service.setTaskGroupCompletedStatus(
+ await service["setTaskGroupCompletedField"](
MOCK_DATA.data_list[taskGroupsListName].rows[0].completed_field,
true
);
@@ -165,43 +191,82 @@ describe("TaskService", () => {
it("completing the highlighted task causes the next highest priority task to be highlighted upon re-evaluation", async () => {
await service.ready();
// Complete highlighted task
- await service.setTaskGroupCompletedStatus(
+ await service["setTaskGroupCompletedField"](
MOCK_DATA.data_list[taskGroupsListName].rows[0].completed_field,
true
);
const { previousHighlightedTaskGroup, newHighlightedTaskGroup } =
- service.evaluateHighlightedTaskGroup();
+ service["evaluateHighlightedTaskGroup"]();
expect(previousHighlightedTaskGroup).toBe(MOCK_DATA.data_list[taskGroupsListName].rows[0].id);
expect(newHighlightedTaskGroup).toBe(MOCK_DATA.data_list[taskGroupsListName].rows[2].id);
});
it("when all tasks are completed, the highlighted task group is set to ''", async () => {
await service.ready();
// Complete all tasks
- await service.setTaskGroupCompletedStatus(
+ await service["setTaskGroupCompletedField"](
MOCK_DATA.data_list[taskGroupsListName].rows[0].completed_field,
true
);
- await service.setTaskGroupCompletedStatus(
+ await service["setTaskGroupCompletedField"](
MOCK_DATA.data_list[taskGroupsListName].rows[1].completed_field,
true
);
- await service.setTaskGroupCompletedStatus(
+ await service["setTaskGroupCompletedField"](
MOCK_DATA.data_list[taskGroupsListName].rows[2].completed_field,
true
);
- expect(service.evaluateHighlightedTaskGroup().newHighlightedTaskGroup).toBe("");
+ expect(service["evaluateHighlightedTaskGroup"]().newHighlightedTaskGroup).toBe("");
});
it("schedules campaign notifications on change of highlighted task", async () => {
await service.ready();
// Complete highlighted task
- await service.setTaskGroupCompletedStatus(
+ await service["setTaskGroupCompletedField"](
MOCK_DATA.data_list[taskGroupsListName].rows[0].completed_field,
true
);
- service.evaluateHighlightedTaskGroup();
+ service["evaluateHighlightedTaskGroup"]();
await _wait(50);
// scheduleCampaignNotifications() should be called once on init (since the highlighted task group changes),
// and again on the evaluation called above
expect(scheduleCampaignNotificationsSpy).toHaveBeenCalledTimes(2);
});
+
+ it("evaluate task completion: should return null if taskRow is not found", async () => {
+ const result = await service["evaluateTaskCompletion"]("dataList", "invalidRowId");
+ expect(result).toBeNull();
+ });
+ it("evaluate task completion: should set parent task completion to true if all child tasks are completed", async () => {
+ const result = await service["evaluateTaskCompletion"]("dataList", "validRowId");
+ expect(service["setTaskCompletion"]).toHaveBeenCalledWith(
+ "dataList",
+ "validRowId",
+ true,
+ "completed_field"
+ );
+ expect(result).toBeTrue();
+ });
+ it("evaluate task completion: should set parent task completion to false if not all child tasks are completed", async () => {
+ fetchTaskRowsSpy.and.resolveTo([
+ { id: "a", completed: true },
+ { id: "b", completed: false },
+ ]);
+ const result = await service["evaluateTaskCompletion"]("dataList", "validRowId");
+ expect(service["setTaskCompletion"]).toHaveBeenCalledWith(
+ "dataList",
+ "validRowId",
+ false,
+ "completed_field"
+ );
+ expect(result).toBeFalse();
+ });
+ it("evaluate task completion: should log a warning if task row does not have a 'task_child' property", async () => {
+ spyOn(console, "warn");
+ fetchTaskRowSpy.and.resolveTo({
+ completed: false,
+ });
+ await service["evaluateTaskCompletion"]("dataList", "validRowId");
+ expect(console.warn).toHaveBeenCalledWith(
+ '[TASK] evaluate - row "validRowId" in "dataList" has no child tasks to evaluate'
+ );
+ });
});
diff --git a/src/app/shared/services/task/task.service.ts b/src/app/shared/services/task/task.service.ts
index a5707e2a2d..08cb1af72b 100644
--- a/src/app/shared/services/task/task.service.ts
+++ b/src/app/shared/services/task/task.service.ts
@@ -4,10 +4,19 @@ import { AppDataService } from "../data/app-data.service";
import { arrayToHashmap } from "../../utils";
import { AsyncServiceBase } from "../asyncService.base";
import { AppConfigService } from "../app-config/app-config.service";
-import { IAppConfig } from "../../model";
+import { FlowTypes, IAppConfig } from "../../model";
import { CampaignService } from "../../../feature/campaign/campaign.service";
+import { TemplateActionRegistry } from "../../components/template/services/instance/template-action.registry";
+import { DynamicDataService } from "../dynamic-data/dynamic-data.service";
export type IProgressStatus = "notStarted" | "inProgress" | "completed";
+// This is the definition of a task: a row of a data list that has a "completed" column
+export type TaskRow = FlowTypes.Data_listRow<{ completed: boolean }>;
+/**
+ * A task row that includes a value for `task_child`. This value is the name of the data list that contains
+ * a list of subtasks: when all subtasks are completed, the parent task is considered completed
+ */
+export type TaskRowWithChildTasks = TaskRow & { task_child: string };
@Injectable({
providedIn: "root",
@@ -22,16 +31,20 @@ export class TaskService extends AsyncServiceBase {
tasksFeatureEnabled: boolean;
constructor(
- private templateFieldService: TemplateFieldService,
- private appDataService: AppDataService,
private appConfigService: AppConfigService,
- private campaignService: CampaignService
+ private appDataService: AppDataService,
+ private campaignService: CampaignService,
+ private dynamicDataService: DynamicDataService,
+ private templateFieldService: TemplateFieldService,
+ private templateActionRegistry: TemplateActionRegistry
) {
super("Task");
this.registerInitFunction(this.initialise);
+ this.registerTemplateActionHandlers();
}
/**
+ * Determine which task group should be highlighted.
* The highlighted task group should always be the ID of the highest
* priority task_group that is not completed and not skipped
* NB "highest priority" is defined as having the lowest numerical value for the "number" column
@@ -76,7 +89,7 @@ export class TaskService extends AsyncServiceBase {
return { previousHighlightedTaskGroup, newHighlightedTaskGroup };
}
- /** Get the id of the task group stored as higlighted */
+ /** Get the id of the task group stored as highlighted */
public getHighlightedTaskGroup() {
return this.templateFieldService.getField(this.highlightedTaskField);
}
@@ -159,11 +172,11 @@ export class TaskService extends AsyncServiceBase {
// Check whether task group has already been completed
if (!this.templateFieldService.getField(completedField)) {
// If not, set completed field to "true"
- await this.setTaskGroupCompletedStatus(completedField, true);
+ await this.setTaskGroupCompletedField(completedField, true);
newlyCompleted = true;
}
} else {
- await this.setTaskGroupCompletedStatus(completedField, false);
+ await this.setTaskGroupCompletedField(completedField, false);
if (subtasksCompleted) {
progressStatus = "inProgress";
} else {
@@ -174,7 +187,7 @@ export class TaskService extends AsyncServiceBase {
return { subtasksTotal, subtasksCompleted, progressStatus, newlyCompleted };
}
- async setTaskGroupCompletedStatus(completedField: string, isCompleted: boolean) {
+ async setTaskGroupCompletedField(completedField: string, isCompleted: boolean) {
console.log(`Setting ${completedField} to ${isCompleted}`);
await this.templateFieldService.setField(completedField, `${isCompleted}`);
}
@@ -200,6 +213,119 @@ export class TaskService extends AsyncServiceBase {
});
}
+ private registerTemplateActionHandlers() {
+ this.templateActionRegistry.register({
+ task: async ({ args, params }) => {
+ const [actionId] = args;
+ const childActions = {
+ evaluate: async () => {
+ const { data_list_name, row_id } = params;
+ if (!data_list_name) {
+ return console.warn(
+ "[TASK] evaluate action - To evaluate task completion, a data list name must be provided via the data_list_name param"
+ );
+ }
+ if (row_id) {
+ await this.evaluateTaskCompletion(data_list_name, row_id);
+ } else {
+ await this.bulkEvaluateTaskCompletion(data_list_name);
+ }
+ },
+ };
+ if (!(actionId in childActions)) {
+ console.error("task does not have action", actionId);
+ return;
+ }
+ return childActions[actionId]();
+ },
+ });
+ }
+
+ private async bulkEvaluateTaskCompletion(dataListName: string) {
+ const taskRows = await this.fetchTaskRows(dataListName);
+ for (const taskRow of taskRows) {
+ await this.evaluateTaskCompletion(dataListName, taskRow.id, taskRow);
+ }
+ }
+
+ /**
+ * For a given parent task (a row specified by the provided dataListName and rowId),
+ * evaluate its completion status based upon the completion status of its child tasks:
+ * if all child tasks are completed, the "completed" value of parent task is set to `true`, else it is set to `false`.
+ * Expects the task row to have a "task_child" column that contains the name of the data list containing the child tasks.
+ *
+ * @param {string} dataListName - The name of the data list that contains the task row
+ * @param {string} rowId - The ID of the task row to evaluate
+ * @param {TaskRowWithChildTasks} [taskRow] - Optionally provide a task row explicitly to avoid duplicate query to dynamic data
+ * @return {boolean} The completion status of the task group
+ */
+ private async evaluateTaskCompletion(
+ dataListName: string,
+ rowId: string,
+ taskRow?: TaskRowWithChildTasks
+ ): Promise {
+ taskRow = taskRow || (await this.fetchTaskRow(dataListName, rowId));
+ if (!taskRow) return null;
+
+ let taskCompleted = taskRow.completed;
+
+ const subtasksDataListName = taskRow.task_child;
+ if (!subtasksDataListName) {
+ console.warn(
+ `[TASK] evaluate - row "${rowId}" in "${dataListName}" has no child tasks to evaluate`
+ );
+ } else {
+ const subtasks = await this.fetchTaskRows(subtasksDataListName);
+ taskCompleted = subtasks.every((row) => row.completed);
+
+ const taskCompletedField = taskRow["completed_field"];
+ await this.setTaskCompletion(dataListName, rowId, taskCompleted, taskCompletedField);
+ }
+ return taskCompleted;
+ }
+
+ /** Fetch task rows for a whole data list from dynamic data */
+ private async fetchTaskRows(dataListName: string) {
+ const taskRows = await this.dynamicDataService.snapshot(
+ "data_list",
+ dataListName
+ );
+ if (!taskRows) {
+ console.warn(`[TASK] - data list "${dataListName}" not found`);
+ }
+ return taskRows || null;
+ }
+
+ /** Fetch task row from dynamic data */
+ private async fetchTaskRow(dataListName: string, rowId: string) {
+ const taskRows = await this.fetchTaskRows(dataListName);
+ const taskRow = taskRows?.find((row) => row.id === rowId);
+ if (!taskRow) {
+ console.warn(`[TASK] - row "${rowId}" in "${dataListName}" not found`);
+ }
+ return taskRow || null;
+ }
+
+ /**
+ * Update the "completed" value for a given task group.
+ * @param {string} completed_field - If provided, this field will also be updated to support legacy field-based functionality
+ * */
+ private async setTaskCompletion(
+ dataListName: string,
+ rowId: string,
+ completed: boolean,
+ completed_field?: string
+ ) {
+ // Update task's "completed" value in dynamic data
+ await this.dynamicDataService.update("data_list", dataListName, rowId, { completed });
+
+ // Support legacy task group implementation, where task completion is tracked in fields
+ if (completed_field) {
+ await this.setTaskGroupCompletedField(completed_field, completed);
+ this.evaluateHighlightedTaskGroup();
+ }
+ }
+
/**
* TODO: this is not currently implemented, and should likely be reworked as part of a broader overhaul of the task system
*
diff --git a/src/app/shared/services/userMeta/userMeta.service.ts b/src/app/shared/services/userMeta/userMeta.service.ts
index 2f227e63ad..102c6a9c2c 100644
--- a/src/app/shared/services/userMeta/userMeta.service.ts
+++ b/src/app/shared/services/userMeta/userMeta.service.ts
@@ -30,6 +30,7 @@ export class UserMetaService extends AsyncServiceBase {
/** When first initialising ensure a default profile created and any newer defaults are merged with older user profiles */
private async initialise() {
+ this.ensureSyncServicesReady([this.localStorageService]);
await this.ensureAsyncServicesReady([
this.dbService,
this.fieldService,
diff --git a/src/app/shared/utils/utils.ts b/src/app/shared/utils/utils.ts
index 432a586766..23b7ded270 100644
--- a/src/app/shared/utils/utils.ts
+++ b/src/app/shared/utils/utils.ts
@@ -6,6 +6,7 @@ import * as Sentry from "@sentry/angular-ivy";
import { FlowTypes } from "../model";
import { objectToArray } from "../components/template/utils";
import marked from "marked";
+import { markedSmartypantsLite } from "marked-smartypants-lite";
/**
* Generate a random string of characters in base-36 (a-z and 0-9 characters)
@@ -374,7 +375,7 @@ export function stringToIntegerHash(str: string) {
* @param target
* @param ...sources
*/
-export function deepMergeObjects(target: any = {}, ...sources: any) {
+export function deepMergeObjects>(target: T = {} as T, ...sources: any) {
if (!sources.length) return target;
const source = sources.shift();
@@ -392,8 +393,8 @@ export function deepMergeObjects(target: any = {}, ...sources: any) {
return deepMergeObjects(target, ...sources);
}
-export function deepDiffObjects(a: T, b: U) {
- return diff(a, b) as RecursivePartial;
+export function deepDiffObjects(original: T, updated: U) {
+ return diff(original, updated) as RecursivePartial;
}
/**
@@ -498,6 +499,21 @@ export function parseMarkdown(src: string, options?: marked.MarkedOptions) {
marked.setOptions({
renderer,
});
+
+ /**
+ * Interpret quotes and dashes into typographic HTML entities
+ * e.g.
+ * `She said, -- "A 'simple' sentence..." --- unknown`
+ * becomes
+ * `She said, – “A ‘simple’ sentence…” — unknown`
+ *
+ * See
+ * https://github.com/calculuschild/marked-smartypants-lite
+ * and
+ * https://marked.js.org/using_advanced#extensions
+ */
+ marked.use(markedSmartypantsLite());
+
return marked(src, options);
}
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index 161077181d..c9669790be 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -1,21 +1,3 @@
-import packageJson from "../../package.json";
-import deploymentJson from "../../.idems_app/deployments/activeDeployment.json";
-import type { IDeploymentConfig } from "data-models";
-
export const environment = {
- version: packageJson.version,
- deploymentName: deploymentJson.name,
- // HACK - json config converts functions to strings, not strongly typed
- deploymentConfig: deploymentJson as any as IDeploymentConfig,
production: true,
- rapidPro: {
- receiveUrl:
- "https://rapidpro.idems.international/c/fcm/a459e9bf-6462-41fe-9bde-98dbed64e687/receive",
- contactRegisterUrl:
- "https://rapidpro.idems.international/c/fcm/a459e9bf-6462-41fe-9bde-98dbed64e687/register",
- },
- domains: ["plh-demo1.idems.international", "plh-demo.idems.international"],
- chatNonNavigatePaths: ["/chat/action", "/chat/msg-info"],
- variableNameFlows: ["character_names"],
- analytics: { endpoint: "https://apps-server.idems.international/analytics", siteId: 1 },
};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 27059abb45..7262906f05 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -1,27 +1,5 @@
-import packageJson from "../../package.json";
-import deploymentJson from "../../.idems_app/deployments/activeDeployment.json";
-import type { IDeploymentConfig } from "data-models";
-
export const environment = {
- /** App version, as provided by package.json */
- version: packageJson.version,
- deploymentName: deploymentJson.name,
- // HACK - json config converts functions to strings, not strongly typed
- deploymentConfig: deploymentJson as any as IDeploymentConfig,
production: false,
- rapidPro: {
- receiveUrl:
- "https://rapidpro.idems.international/c/fcm/a459e9bf-6462-41fe-9bde-98dbed64e687/receive",
- contactRegisterUrl:
- "https://rapidpro.idems.international/c/fcm/a459e9bf-6462-41fe-9bde-98dbed64e687/register",
- },
- domains: ["plh-demo1.idems.international", "plh-demo.idems.international"],
- chatNonNavigatePaths: ["/chat/action", "/chat/msg-info"],
- variableNameFlows: ["character_names"],
- /** Local Settings */
- analytics: { endpoint: "http://localhost/analytics", siteId: 1 },
- /** Production Settings **/
- // analytics: { endpoint: "https://apps-server.idems.international/analytics", siteId: 1 },
};
// This file can be replaced during build by using the `fileReplacements` array.
diff --git a/src/main.ts b/src/main.ts
index e6c5f3302d..de4c3e402c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,16 +5,41 @@ import { defineCustomElements } from "@ionic/pwa-elements/loader";
import { AppModule } from "./app/app.module";
import { environment } from "./environments/environment";
+import { DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS, IDeploymentRuntimeConfig } from "packages/data-models";
+import { DEPLOYMENT_CONFIG } from "./app/shared/services/deployment/deployment.service";
if (environment.production) {
enableProdMode();
}
-platformBrowserDynamic()
- .bootstrapModule(AppModule)
- .catch((err) => console.log(err));
+/** Load deployment config from asset json, returning default config if not available*/
+const loadConfig = async (): Promise => {
+ const res = await fetch("/assets/app_data/deployment.json");
+ if (res.status === 200) {
+ const deploymentConfig = await res.json();
+ console.log("[DEPLOYMENT] config loaded", deploymentConfig);
+ return deploymentConfig;
+ } else {
+ console.warn("[DEPLOYMENT] config not found, using defaults");
+ return DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS;
+ }
+};
-if (!Capacitor.isNative) {
- // Call PWA custom element loader after the platform has been bootstrapped
- defineCustomElements(window);
-}
+/**
+ * Initialise platform once deployment config has loaded, setting the value of the
+ * global DEPLOYMENT_CONFIG injection token from the loaded json
+ * https://stackoverflow.com/a/62151011
+ *
+ * The configuration is loaded before the rest of the platform so that config values
+ * can be used to configure modules imported in app.module.ts
+ */
+loadConfig().then((deploymentConfig) => {
+ platformBrowserDynamic([{ provide: DEPLOYMENT_CONFIG, useValue: deploymentConfig }])
+ .bootstrapModule(AppModule)
+ .catch((err) => console.log(err));
+
+ if (!Capacitor.isNativePlatform()) {
+ // Call PWA custom element loader after the platform has been bootstrapped
+ defineCustomElements(window);
+ }
+});
diff --git a/src/test/utils.ts b/src/test/utils.ts
new file mode 100644
index 0000000000..00dee05b2b
--- /dev/null
+++ b/src/test/utils.ts
@@ -0,0 +1,20 @@
+import { defer } from "rxjs";
+
+// Utils referenced in v17 angular docs, copied from
+// https://stackblitz.com/edit/spec-has-no-expectations?file=src%2Ftesting%2Fasync-observable-helpers.ts
+
+/**
+ * Create async observable that emits-once and completes
+ * after a JS engine turn
+ */
+export function asyncData(data: T) {
+ return defer(() => Promise.resolve(data));
+}
+
+/**
+ * Create async observable error that errors
+ * after a JS engine turn
+ */
+export function asyncError(errorObject: any) {
+ return defer(() => Promise.reject(errorObject));
+}
diff --git a/src/theme/themes/default.scss b/src/theme/themes/default.scss
index ef063cd5b6..fa54f62b9c 100644
--- a/src/theme/themes/default.scss
+++ b/src/theme/themes/default.scss
@@ -52,6 +52,7 @@
ion-item-background: var(--ion-color-gray-light),
// task-progress-bar-color: var(--ion-color-primary),
// checkbox-background-color: white,
+ // progress-path-line-background, var(--ion-color-gray-100),
);
@include utils.generateTheme($color-primary, $color-secondary, $page-background);
@each $name, $value in $variable-overrides {
diff --git a/src/theme/themes/early_family_math.scss b/src/theme/themes/early_family_math.scss
index e306f5203b..c2e3895a7f 100644
--- a/src/theme/themes/early_family_math.scss
+++ b/src/theme/themes/early_family_math.scss
@@ -47,7 +47,8 @@
// radio-button-font-color: var(--ion-color-primary),
ion-item-background: var(--ion-color-gray-light),
task-progress-bar-color: var(--ion-color-green),
- // checkbox-background-color: white
+ // checkbox-background-color: white,
+ // progress-path-line-background, var(--ion-color-gray-100),
);
@include utils.generateTheme($color-primary, $color-secondary, $page-background);
@each $name, $value in $variable-overrides {
diff --git a/src/theme/themes/pfr.scss b/src/theme/themes/pfr.scss
index 4364811100..38649f3547 100644
--- a/src/theme/themes/pfr.scss
+++ b/src/theme/themes/pfr.scss
@@ -49,6 +49,7 @@
ion-item-background: var(--ion-color-gray-light),
// task-progress-bar-color: var(--ion-color-primary),
// checkbox-background-color: white,
+ // progress-path-line-background, var(--ion-color-gray-100),
);
@include utils.generateTheme($color-primary, $color-secondary, $page-background, $g: $green);
@each $name, $value in $variable-overrides {
diff --git a/src/theme/themes/plh_facilitator_mx.scss b/src/theme/themes/plh_facilitator_mx.scss
index 64dff6dd1e..9e823d425f 100644
--- a/src/theme/themes/plh_facilitator_mx.scss
+++ b/src/theme/themes/plh_facilitator_mx.scss
@@ -47,7 +47,8 @@
// radio-button-font-color: var(--ion-color-primary),
ion-item-background: var(--ion-color-gray-light),
task-progress-bar-color: var(--ion-color-green),
- // checkbox-background-color: white
+ // checkbox-background-color: white,
+ // progress-path-line-background, var(--ion-color-gray-100),
);
@include utils.generateTheme($color-primary, $color-secondary, $page-background);
@each $name, $value in $variable-overrides {
diff --git a/src/theme/themes/professional.scss b/src/theme/themes/professional.scss
index 6c95697ee9..2668bc2cb4 100644
--- a/src/theme/themes/professional.scss
+++ b/src/theme/themes/professional.scss
@@ -47,7 +47,8 @@
// radio-button-font-color: var(--ion-color-primary),
ion-item-background: var(--ion-color-gray-light),
task-progress-bar-color: var(--ion-color-green),
- // checkbox-background-color: white
+ // checkbox-background-color: white,
+ progress-path-line-background: var(--ion-color-gray-100),
);
@include utils.generateTheme($color-primary, $color-secondary, $page-background);
@each $name, $value in $variable-overrides {
diff --git a/yarn.lock b/yarn.lock
index 316e0f835b..fdf1481e23 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -200,14 +200,14 @@ __metadata:
languageName: node
linkType: hard
-"@angular-devkit/core@npm:16.2.12, @angular-devkit/core@npm:^16.0.0":
- version: 16.2.12
- resolution: "@angular-devkit/core@npm:16.2.12"
+"@angular-devkit/core@npm:17.0.10":
+ version: 17.0.10
+ resolution: "@angular-devkit/core@npm:17.0.10"
dependencies:
ajv: 8.12.0
ajv-formats: 2.1.1
jsonc-parser: 3.2.0
- picomatch: 2.3.1
+ picomatch: 3.0.1
rxjs: 7.8.1
source-map: 0.7.4
peerDependencies:
@@ -215,18 +215,18 @@ __metadata:
peerDependenciesMeta:
chokidar:
optional: true
- checksum: 9ffde5156bfa90cbd76f6f707afab8700916b68cf70c3f27db9df2a70c7193e6f92c2cc6b89a536c557b68977d677c8fdedf7065b2fffa5abe9d5b6ef67acb19
+ checksum: 909c113dc0bfe1c2ff74509089bce3ee508eba8f3948011b3ef3f7f9903add48b09edb3c2da48fe502eb50377cd7c1a1b6507bb89d2ed7d8e8b5d1d12353442e
languageName: node
linkType: hard
-"@angular-devkit/core@npm:17.0.10":
- version: 17.0.10
- resolution: "@angular-devkit/core@npm:17.0.10"
+"@angular-devkit/core@npm:17.2.1, @angular-devkit/core@npm:~17.2.1":
+ version: 17.2.1
+ resolution: "@angular-devkit/core@npm:17.2.1"
dependencies:
ajv: 8.12.0
ajv-formats: 2.1.1
- jsonc-parser: 3.2.0
- picomatch: 3.0.1
+ jsonc-parser: 3.2.1
+ picomatch: 4.0.1
rxjs: 7.8.1
source-map: 0.7.4
peerDependencies:
@@ -234,13 +234,13 @@ __metadata:
peerDependenciesMeta:
chokidar:
optional: true
- checksum: 909c113dc0bfe1c2ff74509089bce3ee508eba8f3948011b3ef3f7f9903add48b09edb3c2da48fe502eb50377cd7c1a1b6507bb89d2ed7d8e8b5d1d12353442e
+ checksum: 2ac5852d8d7cb3ae809c70df3c2a1a8cc6ad3ee20246091e5ed2aa0871ee1ec3871f77eb7b10300af5781f56cd667f88ff737d12db34561a0f59640afa92024e
languageName: node
linkType: hard
-"@angular-devkit/core@npm:17.2.1, @angular-devkit/core@npm:~17.2.1":
- version: 17.2.1
- resolution: "@angular-devkit/core@npm:17.2.1"
+"@angular-devkit/core@npm:17.3.8, @angular-devkit/core@npm:^17.0.0":
+ version: 17.3.8
+ resolution: "@angular-devkit/core@npm:17.3.8"
dependencies:
ajv: 8.12.0
ajv-formats: 2.1.1
@@ -253,7 +253,7 @@ __metadata:
peerDependenciesMeta:
chokidar:
optional: true
- checksum: 2ac5852d8d7cb3ae809c70df3c2a1a8cc6ad3ee20246091e5ed2aa0871ee1ec3871f77eb7b10300af5781f56cd667f88ff737d12db34561a0f59640afa92024e
+ checksum: c6d41c56fcfa560f592c0fa8ec30addb50e77bf3be543ad3bee2ed01b7932457156d5ca72d008678a83101a3dcd125c44f2d45063c8685e6e6c914e925b69c26
languageName: node
linkType: hard
@@ -299,19 +299,6 @@ __metadata:
languageName: node
linkType: hard
-"@angular-devkit/schematics@npm:16.2.12, @angular-devkit/schematics@npm:^16.0.0":
- version: 16.2.12
- resolution: "@angular-devkit/schematics@npm:16.2.12"
- dependencies:
- "@angular-devkit/core": 16.2.12
- jsonc-parser: 3.2.0
- magic-string: 0.30.1
- ora: 5.4.1
- rxjs: 7.8.1
- checksum: 475ce9b5d0a95622a0e3541b719cbfcea2a4ba9cf2b92dbcf799626b0e4548384fbe9a66bc95d08bc529ae649dbec0cf0a93779c1a3b47d6b9cce50fc322eb46
- languageName: node
- linkType: hard
-
"@angular-devkit/schematics@npm:17.0.10":
version: 17.0.10
resolution: "@angular-devkit/schematics@npm:17.0.10"
@@ -338,6 +325,19 @@ __metadata:
languageName: node
linkType: hard
+"@angular-devkit/schematics@npm:17.3.8, @angular-devkit/schematics@npm:^17.0.0":
+ version: 17.3.8
+ resolution: "@angular-devkit/schematics@npm:17.3.8"
+ dependencies:
+ "@angular-devkit/core": 17.3.8
+ jsonc-parser: 3.2.1
+ magic-string: 0.30.8
+ ora: 5.4.1
+ rxjs: 7.8.1
+ checksum: a7e2aedb0970a8a243924b122ae030c33dfd5cb9acd818ff7cb3be132b73f048448003152fe1898bd34926580d4f293e9ec8597a9fc45c965460642012489235
+ languageName: node
+ linkType: hard
+
"@angular-eslint/builder@npm:17.2.1":
version: 17.2.1
resolution: "@angular-eslint/builder@npm:17.2.1"
@@ -2039,78 +2039,79 @@ __metadata:
languageName: node
linkType: hard
-"@capacitor-community/file-opener@npm:^1.0.5":
- version: 1.0.5
- resolution: "@capacitor-community/file-opener@npm:1.0.5"
+"@capacitor-community/file-opener@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "@capacitor-community/file-opener@npm:6.0.0"
peerDependencies:
- "@capacitor/core": ^3.0.0 || ^4.0.0 || ^5.0.0
- checksum: c98847e7f083df313911ad85eedbaf07869ea09d88acb6eff738f5c162b614b12683adaa4571d71b1dd808d29e1ec9e4365372482c8a7d16e21bbd3f6b63a306
+ "@capacitor/core": ^6.0.0
+ checksum: 008200294bf280bdc10a1fea0d883b0846eb0c5dd336818e4cdd40950a153d45610362327db194b516ac8ffc917c1c061103edaef3b00ce012cdb1c8b8fe1d54
languageName: node
linkType: hard
-"@capacitor-firebase/authentication@npm:^5.3.0":
- version: 5.4.0
- resolution: "@capacitor-firebase/authentication@npm:5.4.0"
+"@capacitor-firebase/authentication@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "@capacitor-firebase/authentication@npm:6.1.0"
peerDependencies:
- "@capacitor/core": ^5.0.0
- firebase: ^9.0.0 || ^10.0.0
+ "@capacitor/core": ^6.0.0
+ firebase: ^10.9.0
peerDependenciesMeta:
firebase:
optional: true
- checksum: 4eebfa95392d76c2e7a24ab815c7be2b4a1c7a16a7f8f67a1d72db0ccf4f3eab92965aa3f2811bbaacf15ca42c5e05d3b5a78c5deb383accaa86324b104a0482
+ checksum: c70c82576c46333d8d56c03dbe51ceab798cddd7974dbc2aa0f6e287059deea245d17be6343302096a09bd91d8e13b0bf8d6e8417b65ecb86e62573674492df0
languageName: node
linkType: hard
-"@capacitor-firebase/crashlytics@npm:^5.4.1":
- version: 5.4.1
- resolution: "@capacitor-firebase/crashlytics@npm:5.4.1"
+"@capacitor-firebase/crashlytics@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "@capacitor-firebase/crashlytics@npm:6.1.0"
peerDependencies:
- "@capacitor/core": ^5.0.0
+ "@capacitor/core": ^6.0.0
peerDependenciesMeta:
firebase:
optional: true
- checksum: d7b4d6b75e693653931e5a0ace47a546cd2b8c2122acbc066dc04ffacce0e6a5e1a1569610305f7dc2cb877218449f8d2c856ab41f15a797bc89ec734eaf64e6
+ checksum: 9d20b204545e7bb6fed9b858270342634bd8772c84b516ba467d1a6b13a870edcdc1048def10c50f2edebd7fd0c896a2f7728176594cda670d807ed0b5e0f045
languageName: node
linkType: hard
-"@capacitor-firebase/performance@npm:^5.3.0":
- version: 5.4.0
- resolution: "@capacitor-firebase/performance@npm:5.4.0"
+"@capacitor-firebase/performance@npm:^6.1.0":
+ version: 6.1.0
+ resolution: "@capacitor-firebase/performance@npm:6.1.0"
peerDependencies:
- "@capacitor/core": ^5.0.0
- firebase: ^9.0.0 || ^10.0.0
+ "@capacitor/core": ^6.0.0
+ firebase: ^10.9.0
peerDependenciesMeta:
firebase:
optional: true
- checksum: a3af39624d7bee054a00ff937d636a16db8d6974a35912ac014d4e73f743119f8a57429896ae6ee946ee7346b279a57ca122365f0406353eb2f22f3dd4fcfe95
+ checksum: 8aa094e6cece67ed8101cc0d78b2c9a0f3aa9f027de765095963ad95604be4bda70155573a8b8ef190ae571c878ae3fe3f63c688a56268f79840faddd16acaac
languageName: node
linkType: hard
-"@capacitor/android@npm:^5.5.1":
- version: 5.7.0
- resolution: "@capacitor/android@npm:5.7.0"
+"@capacitor/android@npm:^6.0.0":
+ version: 6.1.2
+ resolution: "@capacitor/android@npm:6.1.2"
peerDependencies:
- "@capacitor/core": ^5.7.0
- checksum: 94d1266dba7c23a297edfb597347c9ad77dca6e812024ac2a2898590423dc6fc4b71b52f7e3d5a3967a75fcbc33d3712625b58905b624a95dc6d9fca5c733c98
+ "@capacitor/core": ^6.1.0
+ checksum: 5738cd4777a992b09a2d791c0e90f3933e27cf22a0c5793ac60d34ba4541585c22d602f9252340b2df02eb4a004ec0d4043d1cb86a51825b18169109a27ca984
languageName: node
linkType: hard
-"@capacitor/app@npm:^5.0.6":
- version: 5.0.7
- resolution: "@capacitor/app@npm:5.0.7"
+"@capacitor/app@npm:^6.0.0":
+ version: 6.0.1
+ resolution: "@capacitor/app@npm:6.0.1"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: 29a2615f3c11f8a4e060179418dab5b799207e7c516317853e3505327799d5ad8e8db2b76eaba3363578e417b5f0200dd99375bfaabf0b57420688299b501235
+ "@capacitor/core": ^6.0.0
+ checksum: 3fa08f10421de609e900f8f95fadb674afb54cf7670a7b925b1b98c4c89f9a0676e31da3916347a098347cec48538b1297165ab0bb3525b6acc96c126475591a
languageName: node
linkType: hard
-"@capacitor/cli@npm:^5.5.1":
- version: 5.7.0
- resolution: "@capacitor/cli@npm:5.7.0"
+"@capacitor/cli@npm:^6.0.0":
+ version: 6.1.2
+ resolution: "@capacitor/cli@npm:6.1.2"
dependencies:
"@ionic/cli-framework-output": ^2.2.5
"@ionic/utils-fs": ^3.1.6
- "@ionic/utils-subprocess": ^2.1.11
+ "@ionic/utils-process": ^2.1.11
+ "@ionic/utils-subprocess": 2.1.11
"@ionic/utils-terminal": ^2.3.3
commander: ^9.3.0
debug: ^4.3.4
@@ -2128,97 +2129,97 @@ __metadata:
bin:
cap: bin/capacitor
capacitor: bin/capacitor
- checksum: ae41ea5176817c8e83edecb45ff6158edf1c49b1cb4b052f7a9ff180fa0663045c6bbf2ffb443531b1eb329c08d92a44d0cb0bb18443d6f1dcfb7b2859570601
+ checksum: f3f6b4f134998606cbeb1b2c4e99212d11d88153d2be7be161681d1b362fdebf9aeb8e8bcb67b79e0e74e6e19d42a41c82bfcca5f62e6b154f4f8d22ef0748d9
languageName: node
linkType: hard
-"@capacitor/clipboard@npm:^5.0.6":
- version: 5.0.7
- resolution: "@capacitor/clipboard@npm:5.0.7"
+"@capacitor/clipboard@npm:^6.0.0":
+ version: 6.0.1
+ resolution: "@capacitor/clipboard@npm:6.0.1"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: e56e0fcd6d5bd82f978763843809b1bafa66976223e8b69b9eaa00eed666f100c2873db94aa8e1a84ecf04f050a8ddb44c9299dbc7ced18635cd005af643997e
+ "@capacitor/core": ^6.0.0
+ checksum: 10c33561676bf24fc189527370acc2a46232ae176fbe1375324340c96ac1c287b2615cb45d76f2c91e3173adc4b94936749fe54ef58c89d586d419623dd542da
languageName: node
linkType: hard
-"@capacitor/core@npm:^5.5.1":
- version: 5.7.0
- resolution: "@capacitor/core@npm:5.7.0"
+"@capacitor/core@npm:^6.0.0":
+ version: 6.1.2
+ resolution: "@capacitor/core@npm:6.1.2"
dependencies:
tslib: ^2.1.0
- checksum: 3c7a0ed4bfd3942c333f141adfa6d356d2cf26adef82d5a3a0c73f19137d8285f270f8146bc232ea75705714dac19c9cf9968ba32ac33f6c3fbee89a0e5d9227
+ checksum: 51e5f575c4d96290902c6421fd6c4493e4e865cc95cfd1a207bf80956945cc06e724beb3fc9bfb21f44b0a3559c6a0064137b9dcf2b03b2550bb496c7043dc4e
languageName: node
linkType: hard
-"@capacitor/device@npm:^5.0.6":
- version: 5.0.7
- resolution: "@capacitor/device@npm:5.0.7"
+"@capacitor/device@npm:^6.0.0":
+ version: 6.0.1
+ resolution: "@capacitor/device@npm:6.0.1"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: 5e5d5caa394b9c986b2e0f4f4d85cec6a814e7008e3b6ea410883857b86ecd9b146f2996266d6a1086582bc9471941620f33f4b6dc4683f5b53dc30253b0fcf0
+ "@capacitor/core": ^6.0.0
+ checksum: 65431aba87ca7b5a405897eebea33a27920b7efc19160a0a4d0b0a16fa77590613369c65e5abce904a1c027bcc4280d3cd71535dbcc4ae72c86d3fcb4868dcc5
languageName: node
linkType: hard
-"@capacitor/filesystem@npm:^5.1.4":
- version: 5.2.1
- resolution: "@capacitor/filesystem@npm:5.2.1"
+"@capacitor/filesystem@npm:^6.0.0":
+ version: 6.0.1
+ resolution: "@capacitor/filesystem@npm:6.0.1"
peerDependencies:
- "@capacitor/core": ^5.1.1
- checksum: d043feaf0a9608e15b307f06421603f20c865fa6ff2108d1ae4efa486e9b9979215e8f4f036109a2d12dd1dfb76b0cd4bf4f9c471f375480f03c55f9dc01acd4
+ "@capacitor/core": ^6.0.0
+ checksum: 92e4caa6c66c35a244585002318a6945927bfe6894b4b1b36e16363001301698dba39cfd2e5148807c65b60f9334d69c155b2b48a33ef5edf843c41cf23d509a
languageName: node
linkType: hard
-"@capacitor/ios@npm:^5.7.2":
- version: 5.7.2
- resolution: "@capacitor/ios@npm:5.7.2"
+"@capacitor/ios@npm:^6.0.0":
+ version: 6.1.2
+ resolution: "@capacitor/ios@npm:6.1.2"
peerDependencies:
- "@capacitor/core": ^5.7.0
- checksum: 63d54bc5e44da159730928ad352dff2f9f13fbef132c8ba151b26d4c460fbca9fae32e8be3022d99409d9f3a8ab654637a837e961b092c73efa1e55b3501c9f1
+ "@capacitor/core": ^6.1.0
+ checksum: 452ff6149ca573c29f90fd9be3add3e4e87d32cedecc6e5d9bc79704330c35b10fc161eecfcf00922c80c15c7ddf090abf03fd322c7d376e6529882b00a9212d
languageName: node
linkType: hard
-"@capacitor/local-notifications@npm:^5.0.6":
- version: 5.0.7
- resolution: "@capacitor/local-notifications@npm:5.0.7"
+"@capacitor/local-notifications@npm:^6.0.0":
+ version: 6.1.0
+ resolution: "@capacitor/local-notifications@npm:6.1.0"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: ed68c6b824c8f90b7819a1a9d61897dea0f828957582be3725e9d3c7969e2f23fe62d8a2904c119f1558cb43d6c288604f4d2bdf18656de3fc49e872d778d59e
+ "@capacitor/core": ^6.0.0
+ checksum: 34ea1de959f8362c4d7d42a7621bb2e1df1f6c41054a643ee148303dd406d6d8c9395318e358e7b717b42b505fe1e9abfb45828a85c42d644334ffcf532f2bba
languageName: node
linkType: hard
-"@capacitor/push-notifications@npm:^5.1.0":
- version: 5.1.1
- resolution: "@capacitor/push-notifications@npm:5.1.1"
+"@capacitor/push-notifications@npm:^6.0.0":
+ version: 6.0.2
+ resolution: "@capacitor/push-notifications@npm:6.0.2"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: 3f817e9a1a3f2b81e108c405d0c960c7da53a2dd03697e99b9b314667412fb0d690d6a1aa8b484a8a7b15f5eb24ff285e7e8f2f6b2186e86e3a267fd70124f41
+ "@capacitor/core": ^6.0.0
+ checksum: 293aa7180eb6ff182902ab4655e6cbf10d78788b80ee079a6f9d2011b1f5fa9c66620896ef54ee98ab34621df29b75acbd6c1b20d1153d68527970f2c486d592
languageName: node
linkType: hard
-"@capacitor/share@npm:^5.0.6":
- version: 5.0.7
- resolution: "@capacitor/share@npm:5.0.7"
+"@capacitor/share@npm:^6.0.0":
+ version: 6.0.2
+ resolution: "@capacitor/share@npm:6.0.2"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: a0e2633e154e4edcbe4b0581838e3c17c21e83850efff4f91ea37d833856352eb6843831364196a921bf9ad90b3d12886410b72dbc0d1be34e693abf21161122
+ "@capacitor/core": ^6.0.0
+ checksum: edc0c665ee751a597a399587f0298a5190ec568f8ae776903fdfce9e50e46318c9a1e9a5b05db57e67c8a63dad0194e44e3f60e445bbe8f84e3ed78793203492
languageName: node
linkType: hard
-"@capacitor/splash-screen@npm:^5.0.6":
- version: 5.0.7
- resolution: "@capacitor/splash-screen@npm:5.0.7"
+"@capacitor/splash-screen@npm:^6.0.0":
+ version: 6.0.2
+ resolution: "@capacitor/splash-screen@npm:6.0.2"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: e8062bfbe5e86221b29c60644587094f050428208dae431bcda08d5f171f42b0cc724e2b548ecff08c45239ea400baf3ef592ae012ac615c398ffc9c8e2d7f63
+ "@capacitor/core": ^6.0.0
+ checksum: 9093c476084681d2b60d9a9a740ceaad6c9147b7b68bc08def9da6b56ff5a0aee28f8216c7f6d1a1aeb1f378e5f1cee44521d3517a8df928d3bde1c5d943dc05
languageName: node
linkType: hard
-"@capawesome/capacitor-app-update@npm:^5.0.1":
- version: 5.1.0
- resolution: "@capawesome/capacitor-app-update@npm:5.1.0"
+"@capawesome/capacitor-app-update@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "@capawesome/capacitor-app-update@npm:6.0.0"
peerDependencies:
- "@capacitor/core": ^5.0.0
- checksum: f2e829492e0f65bef77b9e8901e1927c07656d6834ce3b59d68236a89abc01c9846c1b0cf6df271d6fd1b713ef06552f73f582f823b00a387c2c7b8a84151118
+ "@capacitor/core": ^6.0.0
+ checksum: 8fe893f8fcb953b2520e38e395502820d3f633d3a77159584890631393c5beb42462d78a1139c7aa84a46c5316589ca2b1cf4d19222583a9e589416d7df848db
languageName: node
linkType: hard
@@ -4627,14 +4628,14 @@ __metadata:
languageName: node
linkType: hard
-"@ionic/angular-toolkit@npm:^10.0.0":
- version: 10.1.1
- resolution: "@ionic/angular-toolkit@npm:10.1.1"
+"@ionic/angular-toolkit@npm:^11.0.1":
+ version: 11.0.1
+ resolution: "@ionic/angular-toolkit@npm:11.0.1"
dependencies:
- "@angular-devkit/core": ^16.0.0
- "@angular-devkit/schematics": ^16.0.0
- "@schematics/angular": ^16.0.0
- checksum: 555d741d7a4819759adf7f330480914dee312a513e6b92f758763b90c3701f97fcafcc6da9136cffd1ded62ae09f0e18da9103d14ac1ecef693ee4a7e733fc4f
+ "@angular-devkit/core": ^17.0.0
+ "@angular-devkit/schematics": ^17.0.0
+ "@schematics/angular": ^17.0.0
+ checksum: 5d72932b9a60cff71cdf2058ca3ecc35ad505abed095e52e8144e05ffa8b90643c2dcbbf61e0f18c340b7e6cbc3d917d2eb3db264452949e5063fa6bc78eddd7
languageName: node
linkType: hard
@@ -4756,6 +4757,16 @@ __metadata:
languageName: node
linkType: hard
+"@ionic/utils-array@npm:2.1.5":
+ version: 2.1.5
+ resolution: "@ionic/utils-array@npm:2.1.5"
+ dependencies:
+ debug: ^4.0.0
+ tslib: ^2.0.1
+ checksum: eab54e5ae6c3a7d435e420986cd7a0766c00506a829e001ddfc4124542adace6dd0f0c1fc51fa8f5eaa69bd09354a0b5541ff9b39b61763cb0e415936578fd4b
+ languageName: node
+ linkType: hard
+
"@ionic/utils-array@npm:2.1.6, @ionic/utils-array@npm:^2.1.5":
version: 2.1.6
resolution: "@ionic/utils-array@npm:2.1.6"
@@ -4766,6 +4777,18 @@ __metadata:
languageName: node
linkType: hard
+"@ionic/utils-fs@npm:3.1.6":
+ version: 3.1.6
+ resolution: "@ionic/utils-fs@npm:3.1.6"
+ dependencies:
+ "@types/fs-extra": ^8.0.0
+ debug: ^4.0.0
+ fs-extra: ^9.0.0
+ tslib: ^2.0.1
+ checksum: 7cdd69c1aca348192edb588bc24b13491198d4c16428d3b3a176b2d6862a48e3dbb42cf3b677c3c36f3d9ceaf87739c0f633f2ac964092fefe58978863350f04
+ languageName: node
+ linkType: hard
+
"@ionic/utils-fs@npm:3.1.7, @ionic/utils-fs@npm:^3.1.5, @ionic/utils-fs@npm:^3.1.6, @ionic/utils-fs@npm:^3.1.7":
version: 3.1.7
resolution: "@ionic/utils-fs@npm:3.1.7"
@@ -4788,6 +4811,16 @@ __metadata:
languageName: node
linkType: hard
+"@ionic/utils-object@npm:2.1.5":
+ version: 2.1.5
+ resolution: "@ionic/utils-object@npm:2.1.5"
+ dependencies:
+ debug: ^4.0.0
+ tslib: ^2.0.1
+ checksum: 123d1fe5aabe984bd5c93f7e70b3166e47007673218fd9347747c9005be0b10b0c639a8eaf36b77e12337d1cc90171624af06620bf9e9cccb2b8414ad80bc4a5
+ languageName: node
+ linkType: hard
+
"@ionic/utils-object@npm:2.1.6":
version: 2.1.6
resolution: "@ionic/utils-object@npm:2.1.6"
@@ -4798,21 +4831,21 @@ __metadata:
languageName: node
linkType: hard
-"@ionic/utils-process@npm:2.1.11":
- version: 2.1.11
- resolution: "@ionic/utils-process@npm:2.1.11"
+"@ionic/utils-process@npm:2.1.10":
+ version: 2.1.10
+ resolution: "@ionic/utils-process@npm:2.1.10"
dependencies:
- "@ionic/utils-object": 2.1.6
- "@ionic/utils-terminal": 2.3.4
+ "@ionic/utils-object": 2.1.5
+ "@ionic/utils-terminal": 2.3.3
debug: ^4.0.0
signal-exit: ^3.0.3
tree-kill: ^1.2.2
tslib: ^2.0.1
- checksum: 376994e15774778af7b951c22d20c19510fa2009b5ff1c2e1244be6a0d2b059eba5e7b6db6ddd9e3764c326ab35e4446a17e49f2e1deab66b4eccd008f66cc49
+ checksum: 4aa84bcdee08dae2ca0cce37e9109de1de43cba2cb4e4b2aa2324b6fc958ac751251efbf3883959d0d8b02f3ce3beff94f07bd20c4c895ccb270a10aa1360545
languageName: node
linkType: hard
-"@ionic/utils-process@npm:2.1.12":
+"@ionic/utils-process@npm:2.1.12, @ionic/utils-process@npm:^2.1.11":
version: 2.1.12
resolution: "@ionic/utils-process@npm:2.1.12"
dependencies:
@@ -4826,13 +4859,13 @@ __metadata:
languageName: node
linkType: hard
-"@ionic/utils-stream@npm:3.1.6":
- version: 3.1.6
- resolution: "@ionic/utils-stream@npm:3.1.6"
+"@ionic/utils-stream@npm:3.1.5":
+ version: 3.1.5
+ resolution: "@ionic/utils-stream@npm:3.1.5"
dependencies:
debug: ^4.0.0
tslib: ^2.0.1
- checksum: cd207a12fdcfa39c3f215620dee17491aca6bf0fa39cd9c7a9a21188013113aa3f3f9e50e2eae590f2dae9f5411e54a6f9cd3916cd87837be9206ea3fedd65f3
+ checksum: 6211825c64295df1c368650b445c8cb1220417855aa6f0cdec68f4ccd3c5368b5f825911708a7242386e9546aa9050a5f85f9b1a0356c8dd9280d1dd33bcb33a
languageName: node
linkType: hard
@@ -4846,41 +4879,41 @@ __metadata:
languageName: node
linkType: hard
-"@ionic/utils-subprocess@npm:3.0.1":
- version: 3.0.1
- resolution: "@ionic/utils-subprocess@npm:3.0.1"
+"@ionic/utils-subprocess@npm:2.1.11":
+ version: 2.1.11
+ resolution: "@ionic/utils-subprocess@npm:2.1.11"
dependencies:
- "@ionic/utils-array": 2.1.6
- "@ionic/utils-fs": 3.1.7
- "@ionic/utils-process": 2.1.12
- "@ionic/utils-stream": 3.1.7
- "@ionic/utils-terminal": 2.3.5
+ "@ionic/utils-array": 2.1.5
+ "@ionic/utils-fs": 3.1.6
+ "@ionic/utils-process": 2.1.10
+ "@ionic/utils-stream": 3.1.5
+ "@ionic/utils-terminal": 2.3.3
cross-spawn: ^7.0.3
debug: ^4.0.0
tslib: ^2.0.1
- checksum: 24fee310d3293361a130cacdf2b3dde079f402cd099ce3b121d708e7bce7819a83353172c1c2400afd5cdcd0117c252586e3469d800a828ea8c08754ea5cb3e1
+ checksum: f93be70bd164c1386bf4323ebdf6e8672bd0b677cee302d4952162229253639ec3eefd78b955afa752b2571f567e13e4635c23d51969e7d565cfd12b7a0b7df7
languageName: node
linkType: hard
-"@ionic/utils-subprocess@npm:^2.1.11":
- version: 2.1.14
- resolution: "@ionic/utils-subprocess@npm:2.1.14"
+"@ionic/utils-subprocess@npm:3.0.1":
+ version: 3.0.1
+ resolution: "@ionic/utils-subprocess@npm:3.0.1"
dependencies:
"@ionic/utils-array": 2.1.6
"@ionic/utils-fs": 3.1.7
- "@ionic/utils-process": 2.1.11
- "@ionic/utils-stream": 3.1.6
- "@ionic/utils-terminal": 2.3.4
+ "@ionic/utils-process": 2.1.12
+ "@ionic/utils-stream": 3.1.7
+ "@ionic/utils-terminal": 2.3.5
cross-spawn: ^7.0.3
debug: ^4.0.0
tslib: ^2.0.1
- checksum: 26959f40d6bf287f258063ede17da3fe392eb2817db81ef77593f3ea6ac59710d5b3bc437a1713624899c885514797bc1d9170083e31cd271035d2e2694598ea
+ checksum: 24fee310d3293361a130cacdf2b3dde079f402cd099ce3b121d708e7bce7819a83353172c1c2400afd5cdcd0117c252586e3469d800a828ea8c08754ea5cb3e1
languageName: node
linkType: hard
-"@ionic/utils-terminal@npm:2.3.4":
- version: 2.3.4
- resolution: "@ionic/utils-terminal@npm:2.3.4"
+"@ionic/utils-terminal@npm:2.3.3":
+ version: 2.3.3
+ resolution: "@ionic/utils-terminal@npm:2.3.3"
dependencies:
"@types/slice-ansi": ^4.0.0
debug: ^4.0.0
@@ -4891,7 +4924,7 @@ __metadata:
tslib: ^2.0.1
untildify: ^4.0.0
wrap-ansi: ^7.0.0
- checksum: d32fbeb6c7b355717a28ea2b0741c50c2fee5f959c25373f17887f6d8150523bffc54caaa1cd8c585809f94bdcbfd7f13ade63d02a9f122e93ff7d4ca1645698
+ checksum: c551a2c8c094405c1a636638a1e1f2cbac0afe29e9a20c726d7571f20aec5e177cf7ed38c785735808c9bccf7e4f71bd04ee291865dc6f70ac009074fa064974
languageName: node
linkType: hard
@@ -5254,6 +5287,38 @@ __metadata:
languageName: node
linkType: hard
+"@jsonjoy.com/base64@npm:^1.1.1":
+ version: 1.1.2
+ resolution: "@jsonjoy.com/base64@npm:1.1.2"
+ peerDependencies:
+ tslib: 2
+ checksum: 00dbf9cbc6ecb3af0e58288a305cc4ee3dfca9efa24443d98061756e8f6de4d6d2d3764bdfde07f2b03e6ce56db27c8a59b490bd134bf3d8122b4c6b394c7010
+ languageName: node
+ linkType: hard
+
+"@jsonjoy.com/json-pack@npm:^1.0.3":
+ version: 1.1.0
+ resolution: "@jsonjoy.com/json-pack@npm:1.1.0"
+ dependencies:
+ "@jsonjoy.com/base64": ^1.1.1
+ "@jsonjoy.com/util": ^1.1.2
+ hyperdyperid: ^1.2.0
+ thingies: ^1.20.0
+ peerDependencies:
+ tslib: 2
+ checksum: 5c89a01814d5a7464639c3cbd4dbbcbf19165e9e6d6cc3cc985f8a7594fc2c5ac3a29e4f49f9ddf029979ec26ab980960a250db044173798509d0ea388c2ae26
+ languageName: node
+ linkType: hard
+
+"@jsonjoy.com/util@npm:^1.1.2, @jsonjoy.com/util@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "@jsonjoy.com/util@npm:1.3.0"
+ peerDependencies:
+ tslib: 2
+ checksum: a805ca7cf5fc05c6244324a955d96a28797fb8efd60cf22a809a57059de78e4367c72ffb367c82a7ea6ce5622e56f9c696393c5561fbac0fd3c9dc1534d62968
+ languageName: node
+ linkType: hard
+
"@kwsites/file-exists@npm:^1.1.1":
version: 1.1.1
resolution: "@kwsites/file-exists@npm:1.1.1"
@@ -6316,14 +6381,14 @@ __metadata:
languageName: node
linkType: hard
-"@schematics/angular@npm:^16.0.0":
- version: 16.2.12
- resolution: "@schematics/angular@npm:16.2.12"
+"@schematics/angular@npm:^17.0.0":
+ version: 17.3.8
+ resolution: "@schematics/angular@npm:17.3.8"
dependencies:
- "@angular-devkit/core": 16.2.12
- "@angular-devkit/schematics": 16.2.12
- jsonc-parser: 3.2.0
- checksum: 905285d66df42a660e37d88f26c35a962988d6489c46209ce1c47064cd740b700161fc68588447a1fa5552015ea37c0771ff552795f1bf51aec6bc94860d6beb
+ "@angular-devkit/core": 17.3.8
+ "@angular-devkit/schematics": 17.3.8
+ jsonc-parser: 3.2.1
+ checksum: f3fdad7569d2b4c119e1e7d725f8e0701476006a33d141ee6d7fd1781f09b69e80b22486513280c0cd6d5e901a201e4bfa9efb15c6566f135e5e57f6fc3d2512
languageName: node
linkType: hard
@@ -7382,7 +7447,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/jest@npm:^29.5.6":
+"@types/jest@npm:^29.5.12, @types/jest@npm:^29.5.6":
version: 29.5.12
resolution: "@types/jest@npm:29.5.12"
dependencies:
@@ -8067,6 +8132,16 @@ __metadata:
languageName: node
linkType: hard
+"@typescript-eslint/scope-manager@npm:8.2.0":
+ version: 8.2.0
+ resolution: "@typescript-eslint/scope-manager@npm:8.2.0"
+ dependencies:
+ "@typescript-eslint/types": 8.2.0
+ "@typescript-eslint/visitor-keys": 8.2.0
+ checksum: c42fdd44bf06fcf0767ebee33b0d9199365066afa43e8f8fe7243c4b6ecb8d9056126df98d5ce771b4ff9f91132974c0348754ee1862cb6d5ae78e6608530650
+ languageName: node
+ linkType: hard
+
"@typescript-eslint/type-utils@npm:5.62.0":
version: 5.62.0
resolution: "@typescript-eslint/type-utils@npm:5.62.0"
@@ -8146,6 +8221,13 @@ __metadata:
languageName: node
linkType: hard
+"@typescript-eslint/types@npm:8.2.0":
+ version: 8.2.0
+ resolution: "@typescript-eslint/types@npm:8.2.0"
+ checksum: 915fd7667308cb3fe3a50bbeb5b7cfa34ece87732a4e1107e6b4afcde64e6885dc3fcae0a0ccc417e90cd55090e4eeccc1310225be8706a58f522a899be8e626
+ languageName: node
+ linkType: hard
+
"@typescript-eslint/typescript-estree@npm:5.57.1":
version: 5.57.1
resolution: "@typescript-eslint/typescript-estree@npm:5.57.1"
@@ -8220,6 +8302,25 @@ __metadata:
languageName: node
linkType: hard
+"@typescript-eslint/typescript-estree@npm:8.2.0":
+ version: 8.2.0
+ resolution: "@typescript-eslint/typescript-estree@npm:8.2.0"
+ dependencies:
+ "@typescript-eslint/types": 8.2.0
+ "@typescript-eslint/visitor-keys": 8.2.0
+ debug: ^4.3.4
+ globby: ^11.1.0
+ is-glob: ^4.0.3
+ minimatch: ^9.0.4
+ semver: ^7.6.0
+ ts-api-utils: ^1.3.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ checksum: 9bddd72398d24c5fb1a8c6d0481886928d80e6798ae357778574ac2c8b6c6e18cc32e42865167f0698fede9ad5abbdeced0d0b1b45486cf4eeff7ae30bb5b87d
+ languageName: node
+ linkType: hard
+
"@typescript-eslint/utils@npm:5.62.0":
version: 5.62.0
resolution: "@typescript-eslint/utils@npm:5.62.0"
@@ -8272,6 +8373,20 @@ __metadata:
languageName: node
linkType: hard
+"@typescript-eslint/utils@npm:^6.0.0 || ^7.0.0 || ^8.0.0":
+ version: 8.2.0
+ resolution: "@typescript-eslint/utils@npm:8.2.0"
+ dependencies:
+ "@eslint-community/eslint-utils": ^4.4.0
+ "@typescript-eslint/scope-manager": 8.2.0
+ "@typescript-eslint/types": 8.2.0
+ "@typescript-eslint/typescript-estree": 8.2.0
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ checksum: c3b35fc9de40d94c717fd6e0ce77212d78c4a0377dbdc716d82ce1babeb61891e91e566c9108b336fd74095c810f164ce23eb9adc51471975ffec360e332ecff
+ languageName: node
+ linkType: hard
+
"@typescript-eslint/visitor-keys@npm:5.57.1":
version: 5.57.1
resolution: "@typescript-eslint/visitor-keys@npm:5.57.1"
@@ -8312,6 +8427,16 @@ __metadata:
languageName: node
linkType: hard
+"@typescript-eslint/visitor-keys@npm:8.2.0":
+ version: 8.2.0
+ resolution: "@typescript-eslint/visitor-keys@npm:8.2.0"
+ dependencies:
+ "@typescript-eslint/types": 8.2.0
+ eslint-visitor-keys: ^3.4.3
+ checksum: 2f701efa1b63bc4141bbe3a38f0f0b51cbdcd3df3d8ca87232ac1d5cf16e957682302da74106952edb87e6435f2ab99e3b4f66103b94a21c2b5aa7d030926f06
+ languageName: node
+ linkType: hard
+
"@ungap/promise-all-settled@npm:1.1.2":
version: 1.1.2
resolution: "@ungap/promise-all-settled@npm:1.1.2"
@@ -9474,14 +9599,25 @@ __metadata:
languageName: node
linkType: hard
-"axios@npm:^1.5.1, axios@npm:^1.6.0":
- version: 1.6.7
- resolution: "axios@npm:1.6.7"
+"axios@npm:^1.5.1":
+ version: 1.7.3
+ resolution: "axios@npm:1.7.3"
dependencies:
- follow-redirects: ^1.15.4
+ follow-redirects: ^1.15.6
form-data: ^4.0.0
proxy-from-env: ^1.1.0
- checksum: 87d4d429927d09942771f3b3a6c13580c183e31d7be0ee12f09be6d5655304996bb033d85e54be81606f4e89684df43be7bf52d14becb73a12727bf33298a082
+ checksum: bc304d6da974922342aed7c33155934354429cdc7e1ba9d399ab9ff3ac76103f3697eeedf042a634d43cdae682182bcffd942291db42d2be45b750597cdd5eef
+ languageName: node
+ linkType: hard
+
+"axios@npm:^1.7.4":
+ version: 1.7.4
+ resolution: "axios@npm:1.7.4"
+ dependencies:
+ follow-redirects: ^1.15.6
+ form-data: ^4.0.0
+ proxy-from-env: ^1.1.0
+ checksum: 0c17039a9acfe6a566fca8431ba5c1b455c83d30ea6157fec68a6722878fcd30f3bd32d172f6bee0c51fe75ca98e6414ddcd968a87b5606b573731629440bfaf
languageName: node
linkType: hard
@@ -9988,7 +10124,7 @@ __metadata:
languageName: node
linkType: hard
-"bs-logger@npm:0.x":
+"bs-logger@npm:0.x, bs-logger@npm:^0.2.6":
version: 0.2.6
resolution: "bs-logger@npm:0.2.6"
dependencies:
@@ -10225,13 +10361,13 @@ __metadata:
languageName: node
linkType: hard
-"capacitor-blob-writer@npm:^1.1.14":
- version: 1.1.14
- resolution: "capacitor-blob-writer@npm:1.1.14"
+"capacitor-blob-writer@npm:^1.1.17":
+ version: 1.1.17
+ resolution: "capacitor-blob-writer@npm:1.1.17"
peerDependencies:
"@capacitor/core": ">=3.0.0"
"@capacitor/filesystem": ">=1.0.0"
- checksum: 5af741c985ec7ac3e73b2fd5ebd091a63428e51df453fdb868fbfd43ea4b50bb67cdacc904a9ace581501d739648650db68edc3bb5251297aa117c37ce707d7c
+ checksum: 7a2fa2113a00c32e547b7883264823301f8d9ea76259c2b48385e490884db7b3988b6f93c89dd1d37a0b24c3bc043675d5e13fa3f8b59e73374ebff13374d268
languageName: node
linkType: hard
@@ -12407,10 +12543,10 @@ __metadata:
languageName: node
linkType: hard
-"dompurify@npm:^3.0.6":
- version: 3.0.8
- resolution: "dompurify@npm:3.0.8"
- checksum: cac660ccae15a9603f06a85344da868a4c3732d8b57f7998de0f421eb4b9e67d916be52e9bb2a57b2f95b49e994cc50bcd06bb87f2cb2849cf058bdf15266237
+"dompurify@npm:^3.1.3":
+ version: 3.1.6
+ resolution: "dompurify@npm:3.1.6"
+ checksum: cc4fc4ccd9261fbceb2a1627a985c70af231274a26ddd3f643fd0616a0a44099bd9e4480940ce3655612063be4a1fe9f5e9309967526f8c0a99f931602323866
languageName: node
linkType: hard
@@ -12567,7 +12703,7 @@ __metadata:
languageName: node
linkType: hard
-"ejs@npm:^3.1.7":
+"ejs@npm:^3.1.10, ejs@npm:^3.1.7":
version: 3.1.10
resolution: "ejs@npm:3.1.10"
dependencies:
@@ -13507,6 +13643,24 @@ __metadata:
languageName: node
linkType: hard
+"eslint-plugin-jest@npm:^28.8.0":
+ version: 28.8.0
+ resolution: "eslint-plugin-jest@npm:28.8.0"
+ dependencies:
+ "@typescript-eslint/utils": ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependencies:
+ "@typescript-eslint/eslint-plugin": ^6.0.0 || ^7.0.0 || ^8.0.0
+ eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
+ jest: "*"
+ peerDependenciesMeta:
+ "@typescript-eslint/eslint-plugin":
+ optional: true
+ jest:
+ optional: true
+ checksum: c3b39fbb8a1f3843bd6a5d05215e3c896d439fcb1a9959a1e892184c95da33ad2edd37b1c3a76199803ef78b5e6a9cdc0e67f1ac90405461619fe2d3b8d5a278
+ languageName: node
+ linkType: hard
+
"eslint-plugin-jsdoc@npm:40.1.1":
version: 40.1.1
resolution: "eslint-plugin-jsdoc@npm:40.1.1"
@@ -14597,7 +14751,7 @@ __metadata:
languageName: node
linkType: hard
-"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.7, follow-redirects@npm:^1.15.4":
+"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.7, follow-redirects@npm:^1.15.6":
version: 1.15.6
resolution: "follow-redirects@npm:1.15.6"
peerDependenciesMeta:
@@ -14785,23 +14939,23 @@ __metadata:
"@angular/platform-browser": ~17.2.2
"@angular/platform-browser-dynamic": ~17.2.2
"@angular/router": ~17.2.2
- "@capacitor-community/file-opener": ^1.0.5
- "@capacitor-firebase/authentication": ^5.3.0
- "@capacitor-firebase/crashlytics": ^5.4.1
- "@capacitor-firebase/performance": ^5.3.0
- "@capacitor/android": ^5.5.1
- "@capacitor/app": ^5.0.6
- "@capacitor/cli": ^5.5.1
- "@capacitor/clipboard": ^5.0.6
- "@capacitor/core": ^5.5.1
- "@capacitor/device": ^5.0.6
- "@capacitor/filesystem": ^5.1.4
- "@capacitor/ios": ^5.7.2
- "@capacitor/local-notifications": ^5.0.6
- "@capacitor/push-notifications": ^5.1.0
- "@capacitor/share": ^5.0.6
- "@capacitor/splash-screen": ^5.0.6
- "@capawesome/capacitor-app-update": ^5.0.1
+ "@capacitor-community/file-opener": ^6.0.0
+ "@capacitor-firebase/authentication": ^6.1.0
+ "@capacitor-firebase/crashlytics": ^6.1.0
+ "@capacitor-firebase/performance": ^6.1.0
+ "@capacitor/android": ^6.0.0
+ "@capacitor/app": ^6.0.0
+ "@capacitor/cli": ^6.0.0
+ "@capacitor/clipboard": ^6.0.0
+ "@capacitor/core": ^6.0.0
+ "@capacitor/device": ^6.0.0
+ "@capacitor/filesystem": ^6.0.0
+ "@capacitor/ios": ^6.0.0
+ "@capacitor/local-notifications": ^6.0.0
+ "@capacitor/push-notifications": ^6.0.0
+ "@capacitor/share": ^6.0.0
+ "@capacitor/splash-screen": ^6.0.0
+ "@capawesome/capacitor-app-update": ^6.0.0
"@compodoc/compodoc": ^1.1.23
"@ionic-native/core": ^5.36.0
"@ionic-native/device": ^5.36.0
@@ -14809,7 +14963,7 @@ __metadata:
"@ionic-native/media": ^5.36.0
"@ionic-native/status-bar": ^5.36.0
"@ionic/angular": ^7.7.3
- "@ionic/angular-toolkit": ^10.0.0
+ "@ionic/angular-toolkit": ^11.0.1
"@ionic/cli": ^7.1.5
"@ionic/pwa-elements": ^3.2.2
"@schematics/angular": ~17.0.3
@@ -14829,7 +14983,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": ^6.13.1
"@typescript-eslint/parser": ^6.13.1
bootstrap-datepicker: ^1.10.0
- capacitor-blob-writer: ^1.1.14
+ capacitor-blob-writer: ^1.1.17
clone: ^2.1.2
codelyzer: ^6.0.2
concurrently: ^6.2.0
@@ -14842,7 +14996,7 @@ __metadata:
dexie-observable: 3.0.0-beta.11
dexie-syncable: ^3.0.0-beta.10
document-register-element: ^1.14.10
- dompurify: ^3.0.6
+ dompurify: ^3.1.3
eslint: ^8.54.0
eslint-config-prettier: ^9.1.0
eslint-config-standard-with-typescript: latest
@@ -14874,6 +15028,7 @@ __metadata:
lint-staged: ^15.2.2
lottie-web: ^5.12.2
marked: ^2.1.3
+ marked-smartypants-lite: ^1.0.2
mergexml: ^1.2.3
ng2-nouislider: ^2.0.0
ngx-extended-pdf-viewer: 18.1.9
@@ -16095,6 +16250,13 @@ __metadata:
languageName: node
linkType: hard
+"hyperdyperid@npm:^1.2.0":
+ version: 1.2.0
+ resolution: "hyperdyperid@npm:1.2.0"
+ checksum: 210029d1c86926f09109f6317d143f8b056fc38e8dd11b0c3e3205fc6c6ff8429fb55b4b9c2bce065462719ed9d34366eced387aaa0035d93eb76b306a8547ef
+ languageName: node
+ linkType: hard
+
"i18next@npm:^23.7.6":
version: 23.8.2
resolution: "i18next@npm:23.8.2"
@@ -18869,7 +19031,7 @@ __metadata:
languageName: node
linkType: hard
-"lodash.memoize@npm:4.x":
+"lodash.memoize@npm:4.x, lodash.memoize@npm:^4.1.2":
version: 4.1.2
resolution: "lodash.memoize@npm:4.1.2"
checksum: 9ff3942feeccffa4f1fafa88d32f0d24fdc62fd15ded5a74a5f950ff5f0c6f61916157246744c620173dddf38d37095a92327d5fd3861e2063e736a5c207d089
@@ -19134,15 +19296,6 @@ __metadata:
languageName: node
linkType: hard
-"magic-string@npm:0.30.1":
- version: 0.30.1
- resolution: "magic-string@npm:0.30.1"
- dependencies:
- "@jridgewell/sourcemap-codec": ^1.4.15
- checksum: 7bc7e4493e32a77068f3753bf8652d4ab44142122eb7fb9fa871af83bef2cd2c57518a6769701cd5d0379bd624a13bc8c72ca25ac5655b27e5a61adf1fd38db2
- languageName: node
- linkType: hard
-
"magic-string@npm:0.30.5":
version: 0.30.5
resolution: "magic-string@npm:0.30.5"
@@ -19161,6 +19314,15 @@ __metadata:
languageName: node
linkType: hard
+"magic-string@npm:0.30.8":
+ version: 0.30.8
+ resolution: "magic-string@npm:0.30.8"
+ dependencies:
+ "@jridgewell/sourcemap-codec": ^1.4.15
+ checksum: 79922f4500d3932bb587a04440d98d040170decf432edc0f91c0bf8d41db16d364189bf800e334170ac740918feda62cd39dcc170c337dc18050cfcf00a5f232
+ languageName: node
+ linkType: hard
+
"make-dir@npm:^2.1.0":
version: 2.1.0
resolution: "make-dir@npm:2.1.0"
@@ -19189,7 +19351,7 @@ __metadata:
languageName: node
linkType: hard
-"make-error@npm:1.x, make-error@npm:^1.1.1":
+"make-error@npm:1.x, make-error@npm:^1.1.1, make-error@npm:^1.3.6":
version: 1.3.6
resolution: "make-error@npm:1.3.6"
checksum: b86e5e0e25f7f777b77fabd8e2cbf15737972869d852a22b7e73c17623928fccb826d8e46b9951501d3f20e51ad74ba8c59ed584f610526a48f8ccf88aaec402
@@ -19256,6 +19418,15 @@ __metadata:
languageName: node
linkType: hard
+"marked-smartypants-lite@npm:^1.0.2":
+ version: 1.0.2
+ resolution: "marked-smartypants-lite@npm:1.0.2"
+ peerDependencies:
+ marked: ">=4 <12"
+ checksum: 0ae237f603e43e628d56c11d4b2910a586757bcf6818ad9503203cc819f99f5ee5deecaeec749533bdf980f3b75441a039d7bbed932b649936c5b07916edf13c
+ languageName: node
+ linkType: hard
+
"marked-terminal@npm:^5.1.1":
version: 5.2.0
resolution: "marked-terminal@npm:5.2.0"
@@ -19341,6 +19512,18 @@ __metadata:
languageName: node
linkType: hard
+"memfs@npm:^4.11.1":
+ version: 4.11.1
+ resolution: "memfs@npm:4.11.1"
+ dependencies:
+ "@jsonjoy.com/json-pack": ^1.0.3
+ "@jsonjoy.com/util": ^1.3.0
+ tree-dump: ^1.0.1
+ tslib: ^2.0.0
+ checksum: 20f43af194c4bfc54d469bd63619569a78e7d529566be6fc0755e0a028af8c16d72f260c3f6d29664e0b8626e8f8e49ae7c96d7a7e5f67c472ebddf9a308834d
+ languageName: node
+ linkType: hard
+
"memoizee@npm:^0.4.15":
version: 0.4.15
resolution: "memoizee@npm:0.4.15"
@@ -19628,6 +19811,15 @@ __metadata:
languageName: node
linkType: hard
+"minimatch@npm:^9.0.4":
+ version: 9.0.5
+ resolution: "minimatch@npm:9.0.5"
+ dependencies:
+ brace-expansion: ^2.0.1
+ checksum: 2c035575eda1e50623c731ec6c14f65a85296268f749b9337005210bb2b34e2705f8ef1a358b188f69892286ab99dc42c8fb98a57bde55c8d81b3023c19cea28
+ languageName: node
+ linkType: hard
+
"minimist@npm:^1.1.3, minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
@@ -19838,13 +20030,6 @@ __metadata:
languageName: node
linkType: hard
-"mock-fs@npm:^5.2.0":
- version: 5.2.0
- resolution: "mock-fs@npm:5.2.0"
- checksum: c25835247bd26fa4e0189addd61f98973f61a72741e4d2a5694b143a2069b84978443a7ac0fdb1a71aead99273ec22ff4e9c968de11bbd076db020264c5b8312
- languageName: node
- linkType: hard
-
"modifyjs@npm:0.3.1":
version: 0.3.1
resolution: "modifyjs@npm:0.3.1"
@@ -21647,13 +21832,6 @@ __metadata:
languageName: node
linkType: hard
-"picomatch@npm:2.3.1, picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1":
- version: 2.3.1
- resolution: "picomatch@npm:2.3.1"
- checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf
- languageName: node
- linkType: hard
-
"picomatch@npm:3.0.1":
version: 3.0.1
resolution: "picomatch@npm:3.0.1"
@@ -21668,6 +21846,13 @@ __metadata:
languageName: node
linkType: hard
+"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1":
+ version: 2.3.1
+ resolution: "picomatch@npm:2.3.1"
+ checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf
+ languageName: node
+ linkType: hard
+
"pidtree@npm:0.6.0":
version: 0.6.0
resolution: "pidtree@npm:0.6.0"
@@ -23427,7 +23612,7 @@ __metadata:
"@swc/core": ^1.3.29
"@types/fs-extra": ^9.0.4
"@types/inquirer": ^7.3.1
- "@types/jasmine": ^3.10.6
+ "@types/jest": ^29.5.12
"@types/node-rsa": ^1.1.1
"@types/semver": ^7.3.9
actions: "workspace:*"
@@ -23437,21 +23622,20 @@ __metadata:
commander: ^8.3.0
cordova-res: ^0.15.4
data-models: "workspace:*"
+ eslint-plugin-jest: ^28.8.0
fs-extra: ^9.0.1
inquirer: ^7.3.3
- jasmine: ^3.99.0
- jasmine-spec-reporter: ^7.0.0
- jasmine-ts: ^0.4.0
+ jest: ^29.7.0
log-update: ^4.0.0
- mock-fs: ^5.2.0
+ memfs: ^4.11.1
node-rsa: ^1.1.1
- nodemon: ^2.0.19
open: ^8
p-queue: ^6.6.2
semver: ^7.5.2
shared: "workspace:*"
simple-git: ^3.7.1
subtitles-parser-vtt: ^0.1.0
+ ts-jest: ^29.2.5
ts-morph: ^15.0.0
ts-node: ^10.8.0
ts-node-dev: ^2.0.0
@@ -23548,6 +23732,15 @@ __metadata:
languageName: node
linkType: hard
+"semver@npm:^7.6.0, semver@npm:^7.6.3":
+ version: 7.6.3
+ resolution: "semver@npm:7.6.3"
+ bin:
+ semver: bin/semver.js
+ checksum: 4110ec5d015c9438f322257b1c51fe30276e5f766a3f64c09edd1d7ea7118ecbc3f379f3b69032bacf13116dc7abc4ad8ce0d7e2bd642e26b0d271b56b61a7d8
+ languageName: node
+ linkType: hard
+
"semver@npm:~7.0.0":
version: 7.0.0
resolution: "semver@npm:7.0.0"
@@ -25198,7 +25391,7 @@ __metadata:
"@types/pixelmatch": ^5.2.4
"@types/pngjs": ^6.0.1
archiver: ^5.3.0
- axios: ^1.6.0
+ axios: ^1.7.4
boxen: ^5.1.2
chalk: ^4.1.2
commander: ^8.2.0
@@ -25254,6 +25447,15 @@ __metadata:
languageName: node
linkType: hard
+"thingies@npm:^1.20.0":
+ version: 1.21.0
+ resolution: "thingies@npm:1.21.0"
+ peerDependencies:
+ tslib: ^2
+ checksum: 283a2785e513dc892822dd0bbadaa79e873a7fc90b84798164717bf7cf837553e0b4518d8027b2307d8f6fc6caab088fa717112cd9196c6222763cc3cc1b7e79
+ languageName: node
+ linkType: hard
+
"throttleit@npm:^1.0.0":
version: 1.0.1
resolution: "throttleit@npm:1.0.1"
@@ -25466,6 +25668,15 @@ __metadata:
languageName: node
linkType: hard
+"tree-dump@npm:^1.0.1":
+ version: 1.0.2
+ resolution: "tree-dump@npm:1.0.2"
+ peerDependencies:
+ tslib: 2
+ checksum: 3b0cae6cd74c208da77dac1c65e6a212f5678fe181f1dfffbe05752be188aa88e56d5d5c33f5701d1f603ffcf33403763f722c9e8e398085cde0c0994323cb8d
+ languageName: node
+ linkType: hard
+
"tree-kill@npm:1.2.2, tree-kill@npm:^1.2.2":
version: 1.2.2
resolution: "tree-kill@npm:1.2.2"
@@ -25498,6 +25709,15 @@ __metadata:
languageName: node
linkType: hard
+"ts-api-utils@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "ts-api-utils@npm:1.3.0"
+ peerDependencies:
+ typescript: ">=4.2.0"
+ checksum: c746ddabfdffbf16cb0b0db32bb287236a19e583057f8649ee7c49995bb776e1d3ef384685181c11a1a480369e022ca97512cb08c517b2d2bd82c83754c97012
+ languageName: node
+ linkType: hard
+
"ts-interface-checker@npm:^0.1.9":
version: 0.1.13
resolution: "ts-interface-checker@npm:0.1.13"
@@ -25538,6 +25758,43 @@ __metadata:
languageName: node
linkType: hard
+"ts-jest@npm:^29.2.5":
+ version: 29.2.5
+ resolution: "ts-jest@npm:29.2.5"
+ dependencies:
+ bs-logger: ^0.2.6
+ ejs: ^3.1.10
+ fast-json-stable-stringify: ^2.1.0
+ jest-util: ^29.0.0
+ json5: ^2.2.3
+ lodash.memoize: ^4.1.2
+ make-error: ^1.3.6
+ semver: ^7.6.3
+ yargs-parser: ^21.1.1
+ peerDependencies:
+ "@babel/core": ">=7.0.0-beta.0 <8"
+ "@jest/transform": ^29.0.0
+ "@jest/types": ^29.0.0
+ babel-jest: ^29.0.0
+ jest: ^29.0.0
+ typescript: ">=4.3 <6"
+ peerDependenciesMeta:
+ "@babel/core":
+ optional: true
+ "@jest/transform":
+ optional: true
+ "@jest/types":
+ optional: true
+ babel-jest:
+ optional: true
+ esbuild:
+ optional: true
+ bin:
+ ts-jest: cli.js
+ checksum: d60d1e1d80936f6002b1bb27f7e062408bc733141b9d666565503f023c340a3196d506c836a4316c5793af81a5f910ab49bb9c13f66e2dc66de4e0f03851dbca
+ languageName: node
+ linkType: hard
+
"ts-loader@npm:^8.4.0":
version: 8.4.0
resolution: "ts-loader@npm:8.4.0"
@@ -25806,6 +26063,13 @@ __metadata:
languageName: node
linkType: hard
+"tslib@npm:^2.0.0":
+ version: 2.7.0
+ resolution: "tslib@npm:2.7.0"
+ checksum: 1606d5c89f88d466889def78653f3aab0f88692e80bb2066d090ca6112ae250ec1cfa9dbfaab0d17b60da15a4186e8ec4d893801c67896b277c17374e36e1d28
+ languageName: node
+ linkType: hard
+
"tsup@npm:^7.2.0":
version: 7.2.0
resolution: "tsup@npm:7.2.0"