diff --git a/.github/pr-labeler.config.yml b/.github/pr-labeler.config.yml index 98b7d9b45f..5396f0eb51 100644 --- a/.github/pr-labeler.config.yml +++ b/.github/pr-labeler.config.yml @@ -1,3 +1,12 @@ -# Additional labels to assign based on file changes -"documentation": - - documentation/**/* +# Patterns to match to auto-assign labels based on path changes +# https://github.com/actions/labeler/tree/v5/?tab=readme-ov-file#match-object + +# Documentation +'documentation': + - changed-files: + - any-glob-to-any-file: 'documentation/**/*' + +# Scripts +'scripts': + - changed-files: + - any-glob-to-any-file: 'packages/scripts/**/*' \ No newline at end of file diff --git a/.github/workflows/deprecated/build-and-upload.yml b/.github/workflows/deprecated/build-and-upload.yml index 8a9debdb51..761bb6faab 100644 --- a/.github/workflows/deprecated/build-and-upload.yml +++ b/.github/workflows/deprecated/build-and-upload.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16] + node-version: [20] steps: - name: Install crcmod diff --git a/.github/workflows/deprecated/deployment-hosting.yml b/.github/workflows/deprecated/deployment-hosting.yml index eeb3b20be0..89317c308d 100644 --- a/.github/workflows/deprecated/deployment-hosting.yml +++ b/.github/workflows/deprecated/deployment-hosting.yml @@ -34,7 +34,7 @@ jobs: echo "DEPLOYMENT_NAME=${{needs.build.outputs.DEPLOYMENT_NAME}}" >> $GITHUB_ENV echo "GIT_SHA=${{needs.build.outputs.GIT_SHA}}" >> $GITHUB_ENV - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www - name: Extract Build folder @@ -52,7 +52,7 @@ jobs: SENTRY_PROJECT: ${{env.DEPLOYMENT_NAME}} continue-on-error: true - name: Store sourcemaps artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: sourcemaps-$GIT_SHA path: www/*.map @@ -65,7 +65,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www - name: Extract Build folder diff --git a/.github/workflows/deprecated/pr-build.yml b/.github/workflows/deprecated/pr-build.yml index 115e29aab6..d83084062c 100644 --- a/.github/workflows/deprecated/pr-build.yml +++ b/.github/workflows/deprecated/pr-build.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [16] + node-version: [20] steps: - name: Get PR Number diff --git a/.github/workflows/deprecated/sourcemaps-upload.yml b/.github/workflows/deprecated/sourcemaps-upload.yml index 5410a65c8f..09db67796c 100644 --- a/.github/workflows/deprecated/sourcemaps-upload.yml +++ b/.github/workflows/deprecated/sourcemaps-upload.yml @@ -31,7 +31,7 @@ jobs: - name: Node ${{ matrix.node-version }} uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20.17.0 cache: 'yarn' - name: Populate environment config env: @@ -60,7 +60,7 @@ jobs: SENTRY_PROJECT: ${{env.DEPLOYMENT_NAME}} continue-on-error: true - name: Store sourcemaps artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: sourcemaps-$SHA_SHORT path: www/*.map diff --git a/.github/workflows/deprecated/test-e2e.yml b/.github/workflows/deprecated/test-e2e.yml index bc99c79002..b66dece188 100644 --- a/.github/workflows/deprecated/test-e2e.yml +++ b/.github/workflows/deprecated/test-e2e.yml @@ -28,7 +28,7 @@ jobs: - name: Node ${{ matrix.node-version }} uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20.17.0 cache: 'yarn' - name: Populate firebaseConfig.ts env: diff --git a/.github/workflows/documentation-lint.yml b/.github/workflows/documentation-lint.yml index 225d27eff6..4ac85a0e79 100644 --- a/.github/workflows/documentation-lint.yml +++ b/.github/workflows/documentation-lint.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20.17.0 cache: 'yarn' - run: yarn install --immutable - name: Spellcheck diff --git a/.github/workflows/gh-pr-label-paths.yml b/.github/workflows/gh-pr-label-paths.yml new file mode 100644 index 0000000000..55f6f46c13 --- /dev/null +++ b/.github/workflows/gh-pr-label-paths.yml @@ -0,0 +1,26 @@ +# Automatically apply labels to PR based on filepaths of modified files +name: Github PR Label Paths +on: + pull_request: + # https://frontside.com/blog/2020-05-26-github-actions-pull_request/ + types: + - opened + - synchronize +jobs: + label: + if: ${{ !contains(github.event.pull_request.labels.*.name , 'no-autolabel')}} + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + # Checkout repo to access local templates (if updated) + - name: checkout + uses: actions/checkout@main + # Assign labels based on files modified + # https://github.com/actions/labeler + - name: Assign PR path Labels + uses: actions/labeler@v5 + with: + configuration-path: .github/pr-labeler.config.yml + sync-labels: false diff --git a/.github/workflows/gh-pr-label-title.yml b/.github/workflows/gh-pr-label-title.yml new file mode 100644 index 0000000000..8dee66e103 --- /dev/null +++ b/.github/workflows/gh-pr-label-title.yml @@ -0,0 +1,51 @@ +# Automatically apply labels to PR based on title (if written in semantic format) +name: Github PR Label Title +on: + pull_request: + # https://frontside.com/blog/2020-05-26-github-actions-pull_request/ + types: + - opened + - ready_for_review +jobs: + label: + # Avoid re-label if title not changed or using custom label to prevent + # https://github.com/orgs/community/discussions/101695 + if: | + ${{ !contains(github.event.pull_request.labels.*.name , 'no-autolabel')}} && + (event.type == 'opened' || event.changes.title.from) + runs-on: ubuntu-latest + steps: + # Checkout repo to access local templates (if updated) + - name: checkout + uses: actions/checkout@main + # Check if PR title matches conventional commit standard + # Not strictly enforced so allow continue on error + # https://github.com/marketplace/actions/semantic-pull-request + - name: Validate PR title + uses: amannn/action-semantic-pull-request@v5 + continue-on-error: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Assign labels based on conventional PR title + # https://github.com/marketplace/actions/conventional-release-labels + - name: Assign PR Name Labels + uses: bcoe/conventional-release-labels@v1.3.1 + with: + # Labels assigned based on pr name prefix + type_labels: | + { + "breaking": "breaking", + "chore": "maintenance", + "docs": "documentation", + "feat": "feature", + "fix": "fix", + "refactor": "maintenance", + "Breaking": "breaking", + "Chore": "maintenance", + "Docs": "documentation", + "Feat": "feature", + "Fix": "fix", + "Refactor": "maintenance" + } + # Do not ignore any labels (default ignores chore:) + ignored_types: '[]' diff --git a/.github/workflows/gh-pr-labeler.yml b/.github/workflows/gh-pr-labeler.yml deleted file mode 100644 index 6ecd6b7ecc..0000000000 --- a/.github/workflows/gh-pr-labeler.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Github PR Labeler -on: - # TODO - replace with pull_request_target when working in master - pull_request: - types: - - opened - - edited - - synchronize -jobs: - label: - runs-on: ubuntu-latest - steps: - # Check if PR title matches conventional commit standard - # Not strictly enforced so allow continue on error - # https://github.com/marketplace/actions/semantic-pull-request - - name: Validate PR title - uses: amannn/action-semantic-pull-request@v5 - continue-on-error: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Assign labels based on conventional PR title - # https://github.com/marketplace/actions/conventional-release-labels - - name: Assign PR Name Labels - uses: bcoe/conventional-release-labels@v1.3.1 - with: - # Labels assigned based on pr name prefix - type_labels: | - { - "breaking": "breaking", - "chore": "maintenance", - "docs": "documentation", - "feat": "feature", - "fix": "fix", - "refactor": "maintenance" - } - # Do not ignore any labels (default ignores chore:) - ignored_types: '[]' - # Assign labels based on files modified - - name: Assign PR path Labels - uses: actions/labeler@v4 - with: - configuration-path: .github/pr-labeler.config.yml - - \ No newline at end of file diff --git a/.github/workflows/reusable-android-build.yml b/.github/workflows/reusable-android-build.yml index 8ef127275f..0f957b891f 100644 --- a/.github/workflows/reusable-android-build.yml +++ b/.github/workflows/reusable-android-build.yml @@ -85,7 +85,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.17.0 - uses: actions/cache/restore@v3 id: cache with: @@ -104,7 +104,7 @@ jobs: run: yarn workflow android - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www @@ -134,7 +134,7 @@ jobs: run: ./gradlew :app:assembleDebug - name: Upload debug apk - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: debug_apk path: android/app/build/outputs/apk/debug/app-debug.apk @@ -155,7 +155,7 @@ jobs: keyPassword: ${{ env.KEY_PASSWORD }} - name: Upload release bundle - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: release_bundle path: ${{steps.sign_aab.outputs.signedReleaseFile}} diff --git a/.github/workflows/reusable-android-release.yml b/.github/workflows/reusable-android-release.yml index c4a4271e1e..8f60a46ede 100644 --- a/.github/workflows/reusable-android-release.yml +++ b/.github/workflows/reusable-android-release.yml @@ -33,7 +33,7 @@ jobs: - name: Download Build Artifact id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: release_bundle path: ./ diff --git a/.github/workflows/reusable-app-build.yml b/.github/workflows/reusable-app-build.yml index 674de36f38..32edea96c0 100644 --- a/.github/workflows/reusable-app-build.yml +++ b/.github/workflows/reusable-app-build.yml @@ -56,21 +56,21 @@ jobs: steps: - name: Check out app code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: "IDEMSInternational/open-app-builder.git" ref: ${{env.APP_CODE_BRANCH}} - name: Checkout parent repo if needed if: env.PARENT_DEPLOYMENT_REPO != '' - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: ".idems_app/deployments/${{env.PARENT_DEPLOYMENT_NAME}}" repository: ${{env.PARENT_DEPLOYMENT_REPO}} ref: ${{env.PARENT_DEPLOYMENT_BRANCH}} - name: Checkout deployment - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{inputs.branch}} path: ".idems_app/deployments/${{env.DEPLOYMENT_NAME}}" @@ -81,30 +81,40 @@ jobs: run: echo "${{env.DEPLOYMENT_PRIVATE_KEY}}" > ./.idems_app/deployments/${{env.DEPLOYMENT_NAME}}/encrypted/private.key - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.17.0 + - - name: Cache node modules - uses: actions/cache@v3 + ############################################################################# + # Node Modules + # Manually restore any previous cache to speed install + # As immutable install will not change cache only save new cache if not hit + # Uses fine-grained methods from https://github.com/actions/cache + ############################################################################# + - uses: actions/cache/restore@v4 + id: cache with: path: ./.yarn/cache - # If cachebusting required (e.g. breaking yarn changes on update) change `v1` to another number - key: ${{ runner.os }}-node-modules-yarn-v1-${{ hashFiles('**/yarn.lock') }} + key: ${{ runner.os }}-node-modules-yarn-v1-${{ hashFiles('yarn.lock') }} restore-keys: | ${{ runner.os }}-node-modules-yarn-v1- - - name: Install node modules - run: yarn install - + run: yarn install --immutable + - uses: actions/cache/save@v4 + if: steps.cache.outputs.cache-hit != 'true' + with: + path: ./.yarn/cache + key: ${{ runner.os }}-node-modules-yarn-v1-${{ hashFiles('yarn.lock') }} + - name: Set deployment run: yarn workflow deployment set $DEPLOYMENT_NAME --skip-refresh - + - name: Build run: yarn build ${{inputs.build-flags}} - name: Upload artifact - uses: actions/upload-pages-artifact@v1.0.8 + uses: actions/upload-pages-artifact@v3 with: path: "www/" name: www diff --git a/.github/workflows/reusable-appetize.yml b/.github/workflows/reusable-appetize.yml index b2dceae7d3..3919b87302 100644 --- a/.github/workflows/reusable-appetize.yml +++ b/.github/workflows/reusable-appetize.yml @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@v3 - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: debug_apk path: ./ diff --git a/.github/workflows/reusable-content-sync.yml b/.github/workflows/reusable-content-sync.yml index 531ddc9376..bcccc47936 100644 --- a/.github/workflows/reusable-content-sync.yml +++ b/.github/workflows/reusable-content-sync.yml @@ -75,7 +75,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.17.0 - name: Cache node modules uses: actions/cache@v3 diff --git a/.github/workflows/reusable-deploy-pr-preview.yml b/.github/workflows/reusable-deploy-pr-preview.yml index 614dfb436d..dad03cc686 100644 --- a/.github/workflows/reusable-deploy-pr-preview.yml +++ b/.github/workflows/reusable-deploy-pr-preview.yml @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@v3 - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www diff --git a/.github/workflows/reusable-deploy-web-gh-pages.yml b/.github/workflows/reusable-deploy-web-gh-pages.yml new file mode 100644 index 0000000000..d87dc133af --- /dev/null +++ b/.github/workflows/reusable-deploy-web-gh-pages.yml @@ -0,0 +1,98 @@ +################################################################################## +# About +################################################################################## +# Reuseable workflow to be called from content repos. +# Build and deploy app to github pages +# +# Version : 1.0 +# +################################################################################## +# Configuration +# Must enable github pages in settings, e.g. +# https://github.com/{org}/{repo}/settings/pages +# +# Variables +# +# GH_PAGES_BASE - url path that pages deployed to. +# If using gh pages domain should be name of repo for page to display at +# https://{org}.github.io/{repo}, e.g. 'my-repo' +# +# If using a custom domain or subdomain can leave use folder name if deployed +# to a child path, e.g. https://my-domain.com/app, or leave blank if not using +# child folder, e.g. https://my-domain.com +################################################################################## + + +################################################################################## +# Main Code +################################################################################## +name: Deploy Web GH Pages +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +concurrency: + group: "deploy_web_gh_pages" + cancel-in-progress: true + +on: + workflow_call: + inputs: + GH_PAGES_BASE: + description: 'URL prefix used when deploying to folder path, e.g. /my-repo-name' + default: ${{vars.GH_PAGES_BASE || ''}} + required: false + type: string + + +jobs: + build: + uses: ./.github/workflows/reusable-app-build.yml + secrets: inherit + with: + # If base url provided pass to build command with both initial and trailing '/', i.e. /my-repo/ + # https://stackoverflow.com/a/54409380 + build-flags: ${{inputs.GH_PAGES_BASE && format('--base-href /{0}/',inputs.GH_PAGES_BASE) }} + + # Github pages doesn't support single-page-apps, so use post-build to add 404 workaround + # https://github.com/isaacs/github/issues/408 + # https://github.com/orgs/community/discussions/64096 + # TODO - could consider populating individual files for all known template paths (for better SEO) + post_build: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download Build Artifact + uses: actions/download-artifact@v4 + with: + name: www + - name: Extract Build folder + run: | + mkdir www + tar -xf artifact.tar --directory www + - name: Add fallback redirect + run: | + cp www/index.html www/404.html + - name: Upload updated artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "www/" + + deploy: + needs: post_build + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + outputs: + urls: ${{ steps.deploy.outputs.urls }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + +################################################################################## +# Useful Links +################################################################################## +# https://github.com/marketplace/actions/deploy-github-pages-site diff --git a/.github/workflows/reusable-deploy-web-preview.yml b/.github/workflows/reusable-deploy-web-preview.yml index f95164d885..1eff632cbf 100644 --- a/.github/workflows/reusable-deploy-web-preview.yml +++ b/.github/workflows/reusable-deploy-web-preview.yml @@ -36,12 +36,16 @@ on: description: Firebase site for hosting type: string default: ${{vars.FIREBASE_HOSTING_TARGET}} + secrets: # Declare secrets you expect to receive + FIREBASE_SERVICE_ACCOUNT: + required: true jobs: build_action: uses: ./.github/workflows/reusable-app-build.yml secrets: inherit + # TODO - split post_build and deploy deploy: needs: build_action runs-on: ubuntu-latest @@ -52,7 +56,7 @@ jobs: - uses: actions/checkout@v3 - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www @@ -61,6 +65,7 @@ jobs: mkdir www tar -xf artifact.tar --directory www + # TODO - use templated files # Create a .firebaserc file mapping any firebase deployment host targets (required if multi-host projects) # e.g. {"projects": {"default": "my_app"},"targets": {"my_app": {"hosting": {"my_app_dev":["my_app_dev"]} } } - name: Populate Firebase Targets @@ -101,7 +106,7 @@ jobs: uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: "${{ secrets.GITHUB_TOKEN }}" - firebaseServiceAccount: "${{ env.FIREBASE_SERVICE_ACCOUNT }}" + firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT }}" projectId: "${{ env.FIREBASE_PROJECT_ID }}" channelId: "${{ env.FIREBASE_HOSTING_CHANNEL }}" target: "${{inputs.firebase-host}}" diff --git a/.github/workflows/test-preview.yml b/.github/workflows/test-preview.yml index 61e1552aaf..ab24f91fc2 100644 --- a/.github/workflows/test-preview.yml +++ b/.github/workflows/test-preview.yml @@ -29,7 +29,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www - name: Extract Build folder diff --git a/.github/workflows/test-visual.yml b/.github/workflows/test-visual.yml index 35b4f688da..2cd3a9aa5c 100644 --- a/.github/workflows/test-visual.yml +++ b/.github/workflows/test-visual.yml @@ -35,7 +35,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.17.0 - uses: actions/cache/restore@v3 id: cache with: @@ -55,7 +55,7 @@ jobs: # Download build ############################################################################# - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www @@ -79,7 +79,7 @@ jobs: - name: Upload screenshots if: ${{inputs.generate}} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: screenshots-artifact # NOTE - must match SCREENSHOT_ARTIFACT_NAME in code path: packages/test-visual/output/screenshots @@ -95,7 +95,7 @@ jobs: - name: Upload artifact if: ${{inputs.compare}} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-visual-diffs-artifact path: packages/test-visual/output/diffs @@ -104,7 +104,7 @@ jobs: - name: Upload Text Outputs if: ${{inputs.compare}} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: text_output path: packages/test-visual/output/*.txt diff --git a/.github/workflows/web-build.yml b/.github/workflows/web-build.yml index e317a1535f..e12a3a9943 100644 --- a/.github/workflows/web-build.yml +++ b/.github/workflows/web-build.yml @@ -97,7 +97,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.17.0 ############################################################################# # Node Modules @@ -175,7 +175,7 @@ jobs: # Use github pages upload artifact action to compress and upload - name: Upload artifact if: ${{!inputs.skip-upload}} - uses: actions/upload-pages-artifact@v1.0.8 + uses: actions/upload-pages-artifact@v3 with: path: "www/" name: www diff --git a/.gitignore b/.gitignore index 8fc0e5ac83..754a22b7ba 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ src/assets/app_data scripts/config/token.json .eslintcache -report.* # Ignore yarn-v2 dependencies # https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored @@ -92,3 +91,5 @@ android/app/src/main/AndroidManifest.xml android/app/src/main/java/international/idems/debug_app/MainActivity.java android/app/src/main/res/values/strings.xml ios/App/App.xcodeproj/project.pbxproj +ios/App/App/capacitor.config.json +ios/App/App/config.xml diff --git a/.nvmrc b/.nvmrc index 0828ab7947..85aee5a534 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18 \ No newline at end of file +v20 \ No newline at end of file diff --git a/README.md b/README.md index 34ee84d21f..1a7d5f3589 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # Quickstart -## Prequisites +## Prerequisites 1. Download and install [Git](https://git-scm.com/downloads) This will be used to download the repository @@ -14,8 +14,8 @@ 2. Download and install [Git LFS](https://git-lfs.github.com/) This will be used to download any required binary assets, such as images or pdfs -3. Download and install [Node](https://nodejs.org/en/download/) - This is the programming lanugage required to run the project +3. Download and install [Node](https://nodejs.org/en/download/) + This is the programming language required to run the project. We currently support any of the versions prefixed `v20.x.x` or `v18.x.x` 4. Download and Install [Yarn](https://classic.yarnpkg.com/en/docs/install) This manages all 3rd-party code dependencies @@ -23,17 +23,24 @@ ## Installation ### Download the repo with binary assets + +To download the repo into the current working directory, run: ``` -$ git lfs clone https://github.com/IDEMSInternational/open-app-builder.git +git lfs clone https://github.com/IDEMSInternational/open-app-builder.git ``` Note - if you do a regular git clone, you can always run `git lfs fetch --all` later to sync assets ### Install required dependencies +Navigate to the newly cloned directory if you have not done so already: +``` +cd open-app-builder +``` + +From the route of the project, run the following command to download and install the required dependencies: ``` -$ cd open-app-builder -$ yarn install +yarn install ``` -Note - you may have to do this from time to time when content is updated) +Note - you may have to do this from time to time when the code is updated ## Configuration ### Set Deployment @@ -43,9 +50,9 @@ To use an existing deployment, run the following script: ``` yarn workflow deployment set ``` -This will present an interactive list of deployments to select from. +If you have already imported or created a deployment, this will present an interactive list of deployments to select from. -See [Deployment Documentation](https://idemsinternational.github.io/open-app-builder/developers/deployments/) for information about creating and configuring deployments. +If you have no available deployments, see [Deployment Documentation](https://idemsinternational.github.io/open-app-builder/developers/deployments/) for information about creating and configuring deployments. ## Running locally @@ -53,12 +60,16 @@ See [Deployment Documentation](https://idemsinternational.github.io/open-app-bui ``` yarn start ``` -This will start a local server and serve the app in your browser on http://localhost:4200 +This will start a local server and serve the app in your browser on http://localhost:4200. + +## Finishing setup + +In order to complete the setup process, navigate to the relevant section of the documentation from the options below, and continue with the steps outlined. -# For Content Coders +### For Content Authors Please see [Quickstart Authors](https://idemsinternational.github.io/open-app-builder/authors/quickstart/) -# For Developers +### For Developers Please see [Quickstart Developers](https://idemsinternational.github.io/open-app-builder/developers/quickstart/) diff --git a/android/app/build.template.gradle b/android/app/build.template.gradle index 6734a6df65..1aa16647d9 100644 --- a/android/app/build.template.gradle +++ b/android/app/build.template.gradle @@ -2,7 +2,7 @@ apply plugin: 'com.android.application' android { namespace "${APP_ID}" - compileSdkVersion rootProject.ext.compileSdkVersion + compileSdk rootProject.ext.compileSdkVersion defaultConfig { applicationId "${APP_ID}" minSdkVersion rootProject.ext.minSdkVersion diff --git a/android/build.gradle b/android/build.gradle index 1e8c9173a4..aa2d7edafa 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -7,8 +7,8 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.1' - classpath 'com.google.gms:google-services:4.3.15' + classpath 'com.android.tools.build:gradle:8.3.1' + classpath 'com.google.gms:google-services:4.4.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 5c2d1cf016..ccebba7710 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index f9db3bc134..309b4e18db 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sun Dec 04 19:15:48 PST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew index 83f2acfdc3..79a61d421c 100755 --- a/android/gradlew +++ b/android/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,78 +17,113 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -97,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -105,84 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" -fi +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat index 9618d8d960..93e3f59f13 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,10 +25,14 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @@ -37,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -51,7 +55,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -61,38 +65,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/android/variables.gradle b/android/variables.gradle index ba23edaed6..c0069493c5 100644 --- a/android/variables.gradle +++ b/android/variables.gradle @@ -1,18 +1,18 @@ ext { minSdkVersion = 22 - compileSdkVersion = 33 - targetSdkVersion = 33 - androidxActivityVersion = '1.7.0' + compileSdkVersion = 34 + targetSdkVersion = 34 + androidxActivityVersion = '1.8.0' androidxAppCompatVersion = '1.6.1' androidxCoordinatorLayoutVersion = '1.2.0' - firebaseMessagingVersion = '23.1.2' - androidxCoreVersion = '1.10.0' - androidxFragmentVersion = '1.5.6' + firebaseMessagingVersion = '23.3.1' + androidxCoreVersion = '1.12.0' + androidxFragmentVersion = '1.6.2' junitVersion = '4.13.2' androidxJunitVersion = '1.1.5' androidxEspressoCoreVersion = '3.5.1' cordovaAndroidVersion = '10.1.1' rgcfaIncludeGoogle = true - coreSplashScreenVersion = '1.0.0' - androidxWebkitVersion = '1.6.1' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.9.0' } \ No newline at end of file diff --git a/cspell.config.yml b/cspell.config.yml index e02f642f48..991142f97b 100644 --- a/cspell.config.yml +++ b/cspell.config.yml @@ -20,6 +20,7 @@ "linenums", "lottie", "matomo", + "memfs", "metabase", "mkdocs", "mycompany", @@ -30,10 +31,12 @@ "rxdb", "sidemenu", "sourcemaps", + "supabase", "swiper", "templatename", "tmpl", "venv", + "viewbox", ] # flagWords - list of words to be always considered incorrect # This is useful for offensive words and common spelling errors. diff --git a/documentation/CNAME b/documentation/CNAME new file mode 100644 index 0000000000..a1cd0f2121 --- /dev/null +++ b/documentation/CNAME @@ -0,0 +1 @@ +open-app-builder.org diff --git a/documentation/docs/authors/advanced/data-generators.md b/documentation/docs/authors/advanced/data-generators.md new file mode 100644 index 0000000000..195a4a43b2 --- /dev/null +++ b/documentation/docs/authors/advanced/data-generators.md @@ -0,0 +1,101 @@ +# Data Generators + +As seen in [Looping Data](../looping-data), it is possible to use data_lists as a basis for iterative content loops within a single template. + +Data generators extend this idea further, to create multiple content flows from a single input data_list. The generated outputs can be templates, other data_lists or any other flow type. + +## Defining Generators +A generator minimally requires an `input_data_list` source and a `generator` flow type + +*example content_list* + +| flow_type | flow_name | parameter_list | +| --------- |--------- |-------------- | +| data_list | module_list | | +| generator | module_home_gen | input_data_list: module_list | + +**Input Parameters** + +A full list of available parameters is listed below: + +| parameter | description | default | +| ------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------- | +| input_data_list | Source data_list to loop over for generator | (none) | +| output_flow_type | Specify flow_type to save output as | template | +| output_flow_subtype | Specify flow_subtype to save output as | generated | +| output_flow_name | Specify flow_name to save template as. Can reference data from input data_list | {{flow_name}}_{{@gen.id}} | + +!!! Tip + Each row of the input data_list will be processed to generate an output flow. The `@gen` dynamic prefix can be used to refer to values from within the input list row. + +## Example +In this example we will use start with a list of modules, and use a generator to create individual templates to show content from the module + +### Input + +*module_list* + +| id | title | description | +| --------- |--------- |-------------- | +| module_1 | Welcome | Welcome to the course... | +| module_2 | Let's get started | In this unit we will... | + +*module_home_gen* + +The generator provides a simple template to display the module title and text descriptions, and a button that will later be used for navigation + +| type | name | value | +| ----------- | --------| --------------------- | +| title | | @gen.title | +| text | | @gen.description | +| button | | Continue | + + +### Output +Each row of input data will be processed to produce an output template flow + +*module_home_gen_module_1* + +| type | name | value | +| ----------- | --------| --------------------- | +| title | | Welcome | +| text | | Welcome to the course... | +| button | | Continue | + +*module_home_gen_module_2* + +| type | name | value | +| ----------- | --------| --------------------- | +| title | | Let's get started | +| text | | In this unit we will... | +| button | | Continue | + +We could further develop this example to include images, add actions for the button click, mark content as hidden or conditional on data etc. + +## Special Cases + +**Generate output with dynamic references** +If the generated output includes dynamic references that need to be preserved then it may be necessary to double-wrap the reference in the generator. + +For example, a generator is used to create `data_pipe` flows for further processing data. +References to `@row` local processing must use `{{@row}}` to populate correctly. + +| operation | args_list | +| -------------- | ---------------------------------------------- | +| append_columns | task_child: {@gen.id}_{{@row.id}}_article_tasks; | + + +## Additional Info + + +[Google Sheet Demo](#) - *Not currently available* + + +[Live Preview Demo](#) - *Not currently available* + +See details in original [RFC Proposal Doc](https://docs.google.com/document/d/1cK_Mk3nTZIKxux8bygKvujUFkVENMOE_xIgM6w9PlRw/edit) + + + + + diff --git a/documentation/docs/contributors/documentation/running-server.md b/documentation/docs/contributors/documentation/running-server.md index 21ae227fe3..ab941f15e5 100644 --- a/documentation/docs/contributors/documentation/running-server.md +++ b/documentation/docs/contributors/documentation/running-server.md @@ -21,7 +21,7 @@ The scripts below will create a python [virtual environment](https://docs.python ```sh linenums="1" cd documentation - python -m venv .venv + python -m venv .venv # Or `python3`, depending on python installation source .venv/bin/activate pip install -r requirements.txt mkdocs serve diff --git a/documentation/docs/developers/deployments.md b/documentation/docs/developers/deployments.md index 9923fabad5..9452b3135b 100644 --- a/documentation/docs/developers/deployments.md +++ b/documentation/docs/developers/deployments.md @@ -2,8 +2,16 @@ All user-generated content are stored within deployments, alongside app-specific settings such as remote data sources and app strings. +## Import Existing +If an external content repo already exists it is possible to directly import it into the local workspace, instead of first creating a new deployment and then configuring for import. +This can be done via the script +``` +yarn workflow deployment import [url] +``` +Where [url] can be replaced with the url of a github repository where content is stored, e.g. https://github.com/IDEMSInternational/app-debug-content. +You will see the new deployment appear in the `.idems_app` folder and be available for selection ## Create Deployment All deployments are stored in the `.idems_app/deployments` folder, and new deployments can be added by calling the script: @@ -43,9 +51,8 @@ const config = generateDeploymentConfig("example"); // Main Deployment config config.google_drive = { - sheets_folder_ids: [], - assets_folder_ids: [], - } + sheets_folder_ids: [], + assets_folder_ids: [], }; // Deployment app config overrides @@ -90,17 +97,6 @@ const config: IDeploymentConfig = { }, ``` -### Import Existing -If an external content repo already exists it is possible to directly import into the local workspace, instead of first creating a new deployment and then configuring for import. - -This can be done via the script -``` -yarn workflow deployment import [url] -``` -Where [url] can be replaced with the url of a github repository where content is stored - -You will see the new deployment appear in the `.idems_app` folder and be available for selection - ### Sync Content Content from external repos can be synced in the usual way diff --git a/documentation/docs/index.md b/documentation/docs/index.md index 4522d45905..318a938168 100644 --- a/documentation/docs/index.md +++ b/documentation/docs/index.md @@ -8,8 +8,8 @@ 2. Download and install [Git LFS](https://git-lfs.github.com/) This will be used to download any required binary assets, such as images or pdfs -3. Download and install [Node](https://nodejs.org/en/download/) - This is the programming language required to run the project +3. Download and install [Node](https://nodejs.org/en/download/) + This is the programming language required to run the project. We currently support any of the versions prefixed `v20.x.x` or `v18.x.x` 4. Download and Install [Yarn](https://classic.yarnpkg.com/en/docs/install) This manages all 3rd-party code dependencies @@ -17,29 +17,39 @@ ## Installation ### Download the repo with binary assets +!!! tip "Choosing a location to download the repo" + + It is best to download the repo to a folder path that does not include any spaces. If using a user documents directory that includes spaces, e.g. `/user/my name`, you may need to re-open the terminal in a different folder instead. + +To download the repo into the current working directory, run: ``` -$ git lfs clone https://github.com/IDEMSInternational/open-app-builder.git +git lfs clone https://github.com/IDEMSInternational/open-app-builder.git ``` Note - if you do a regular git clone, you can always run `git lfs fetch --all` later to sync assets ### Install required dependencies +Navigate to the newly cloned directory if you have not done so already: ``` -$ cd open-app-builder -$ yarn install +cd open-app-builder ``` -Note - you may have to do this from time to time when content is updated) + +From the route of the project, run the following command to download and install the required dependencies: +``` +yarn install +``` +Note - you may have to do this from time to time when the code is updated ## Configuration ### Set Deployment -Deployments are used to configure data sources (such as google drive) and store generated content. +The app supports using different workspace or deployment configurations. These are stored in [.idems_app/deployments](./.idems_app/deployments) -An initial deployment can be created via the command +To use an existing deployment, run the following script: ``` -yarn workflow deployment create +yarn workflow deployment set ``` -You will be prompted to specify the deployment type, this should be a `New Local Deployment`. You will also be prompted to provide a name. +If you have already imported or created a deployment, this will present an interactive list of deployments to select from. -See [Deployment Documentation](./developers/deployments.md) for more information about configuring deployments +If you have no available deployments, see [Deployment Documentation](https://idemsinternational.github.io/open-app-builder/developers/deployments/) for information about creating and configuring deployments. ## Running locally @@ -47,12 +57,16 @@ See [Deployment Documentation](./developers/deployments.md) for more information ``` yarn start ``` -This will start a local server and serve the app in your browser on http://localhost:4200 +This will start a local server and serve the app in your browser on http://localhost:4200. + +## Finishing setup + +In order to complete the setup process, navigate to the relevant section of the documentation from the options below, and continue with the steps outlined. -# For Content Coders +### For Content Authors Please see [Quickstart Authors](./authors/quickstart.md) -# For Developers +### For Developers -Please see [Quickstart Developers](./developers/quickstart.md) +Please see [Quickstart Developers](/developers/quickstart.md) diff --git a/documentation/mkdocs.yml b/documentation/mkdocs.yml index 6cfc061097..0719cdda81 100644 --- a/documentation/mkdocs.yml +++ b/documentation/mkdocs.yml @@ -17,6 +17,7 @@ nav: - authors/feedback.md - Advanced: - authors/advanced/looping-data.md + - authors/advanced/data-generators.md - authors/advanced/overrides.md - authors/local-sheets.md - Contributors: diff --git a/documentation/requirements.txt b/documentation/requirements.txt index d4fb5e9f13..e270312b75 100644 Binary files a/documentation/requirements.txt and b/documentation/requirements.txt differ diff --git a/ios/.gitignore b/ios/.gitignore index f47029973b..23d202f2ce 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -7,7 +7,3 @@ xcuserdata # Cordova plugins for Capacitor capacitor-cordova-ios-plugins - -# Generated Config files -App/App/capacitor.config.json -App/App/config.xml diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 6c81f443dd..28b9c62bef 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -1,100 +1,105 @@ PODS: - - Capacitor (5.7.2): + - Capacitor (6.1.2): - CapacitorCordova - - CapacitorApp (5.0.7): + - CapacitorApp (6.0.1): - Capacitor - CapacitorBlobWriter (0.0.1): - Capacitor - GCDWebServer (~> 3.0) - - CapacitorClipboard (5.0.7): + - CapacitorClipboard (6.0.1): - Capacitor - - CapacitorCommunityFileOpener (1.0.5): + - CapacitorCommunityFileOpener (6.0.0): - Capacitor - - CapacitorCordova (5.7.2) - - CapacitorDevice (5.0.7): + - CapacitorCordova (6.1.2) + - CapacitorDevice (6.0.1): - Capacitor - - CapacitorFilesystem (5.2.1): + - CapacitorFilesystem (6.0.1): - Capacitor - - CapacitorFirebaseAuthentication (5.4.0): + - CapacitorFirebaseAuthentication (6.1.0): - Capacitor - - CapacitorFirebaseAuthentication/Lite (= 5.4.0) - - FirebaseAuth (~> 10.8) - - CapacitorFirebaseAuthentication/Lite (5.4.0): + - CapacitorFirebaseAuthentication/Lite (= 6.1.0) + - FirebaseAuth (~> 10.25) + - CapacitorFirebaseAuthentication/Lite (6.1.0): - Capacitor - - FirebaseAuth (~> 10.8) - - CapacitorFirebaseCrashlytics (5.4.1): + - FirebaseAuth (~> 10.25) + - CapacitorFirebaseCrashlytics (6.1.0): - Capacitor - - FirebaseCrashlytics (~> 10.8) - - CapacitorFirebasePerformance (5.4.0): + - FirebaseCrashlytics (~> 10.25) + - CapacitorFirebasePerformance (6.1.0): - Capacitor - - FirebasePerformance (~> 10.8) - - CapacitorLocalNotifications (5.0.7): + - FirebasePerformance (~> 10.25) + - CapacitorLocalNotifications (6.1.0): - Capacitor - - CapacitorPushNotifications (5.1.1): + - CapacitorPushNotifications (6.0.2): - Capacitor - - CapacitorShare (5.0.7): + - CapacitorShare (6.0.2): - Capacitor - - CapacitorSplashScreen (5.0.7): + - CapacitorSplashScreen (6.0.2): - Capacitor - - CapawesomeCapacitorAppUpdate (5.1.0): + - CapawesomeCapacitorAppUpdate (6.0.0): - Capacitor - - FirebaseABTesting (10.22.0): + - FirebaseABTesting (10.29.0): - FirebaseCore (~> 10.0) - - FirebaseAppCheckInterop (10.22.0) - - FirebaseAuth (10.22.0): + - FirebaseAppCheckInterop (10.29.0) + - FirebaseAuth (10.29.0): - FirebaseAppCheckInterop (~> 10.17) - FirebaseCore (~> 10.0) - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - GoogleUtilities/Environment (~> 7.8) - GTMSessionFetcher/Core (< 4.0, >= 2.1) - RecaptchaInterop (~> 100.0) - - FirebaseCore (10.22.0): + - FirebaseCore (10.29.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreExtension (10.22.0): + - FirebaseCoreExtension (10.29.0): - FirebaseCore (~> 10.0) - - FirebaseCoreInternal (10.22.0): + - FirebaseCoreInternal (10.29.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseCrashlytics (10.22.0): + - FirebaseCrashlytics (10.29.0): - FirebaseCore (~> 10.5) - FirebaseInstallations (~> 10.0) + - FirebaseRemoteConfigInterop (~> 10.23) - FirebaseSessions (~> 10.5) - GoogleDataTransport (~> 9.2) - GoogleUtilities/Environment (~> 7.8) - nanopb (< 2.30911.0, >= 2.30908.0) - PromisesObjC (~> 2.1) - - FirebaseInstallations (10.22.0): + - FirebaseInstallations (10.29.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) - PromisesObjC (~> 2.1) - - FirebasePerformance (10.22.0): + - FirebasePerformance (10.29.0): - FirebaseCore (~> 10.5) - FirebaseInstallations (~> 10.0) - FirebaseRemoteConfig (~> 10.0) - FirebaseSessions (~> 10.5) - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/ISASwizzler (~> 7.8) - - GoogleUtilities/MethodSwizzler (~> 7.8) + - GoogleUtilities/Environment (~> 7.13) + - GoogleUtilities/ISASwizzler (~> 7.13) + - GoogleUtilities/MethodSwizzler (~> 7.13) + - GoogleUtilities/UserDefaults (~> 7.13) - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseRemoteConfig (10.22.0): + - FirebaseRemoteConfig (10.29.0): - FirebaseABTesting (~> 10.0) - FirebaseCore (~> 10.0) - FirebaseInstallations (~> 10.0) + - FirebaseRemoteConfigInterop (~> 10.23) - FirebaseSharedSwift (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseSessions (10.22.0): + - FirebaseRemoteConfigInterop (10.29.0) + - FirebaseSessions (10.29.0): - FirebaseCore (~> 10.5) - FirebaseCoreExtension (~> 10.0) - FirebaseInstallations (~> 10.0) - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.10) + - GoogleUtilities/Environment (~> 7.13) + - GoogleUtilities/UserDefaults (~> 7.13) - nanopb (< 2.30911.0, >= 2.30908.0) - PromisesSwift (~> 2.1) - - FirebaseSharedSwift (10.22.0) + - FirebaseSharedSwift (10.29.0) - GCDWebServer (3.5.4): - GCDWebServer/Core (= 3.5.4) - GCDWebServer/Core (3.5.4) @@ -102,37 +107,37 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30911.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.13.0): + - GoogleUtilities/AppDelegateSwizzler (7.13.3): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (7.13.0): + - GoogleUtilities/Environment (7.13.3): - GoogleUtilities/Privacy - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/ISASwizzler (7.13.0): + - GoogleUtilities/ISASwizzler (7.13.3): - GoogleUtilities/Privacy - - GoogleUtilities/Logger (7.13.0): + - GoogleUtilities/Logger (7.13.3): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (7.13.0): + - GoogleUtilities/MethodSwizzler (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/Network (7.13.0): + - GoogleUtilities/Network (7.13.3): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.13.0)": + - "GoogleUtilities/NSData+zlib (7.13.3)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (7.13.0) - - GoogleUtilities/Reachability (7.13.0): + - GoogleUtilities/Privacy (7.13.3) + - GoogleUtilities/Reachability (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (7.13.0): + - GoogleUtilities/UserDefaults (7.13.3): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GTMSessionFetcher/Core (3.3.1) + - GTMSessionFetcher/Core (3.5.0) - nanopb (2.30910.0): - nanopb/decode (= 2.30910.0) - nanopb/encode (= 2.30910.0) @@ -174,6 +179,7 @@ SPEC REPOS: - FirebaseInstallations - FirebasePerformance - FirebaseRemoteConfig + - FirebaseRemoteConfigInterop - FirebaseSessions - FirebaseSharedSwift - GCDWebServer @@ -220,38 +226,39 @@ EXTERNAL SOURCES: :path: "../../node_modules/@capawesome/capacitor-app-update" SPEC CHECKSUMS: - Capacitor: fc7ef6d935eafb0df9eaaf109ca69be16c51a2d2 - CapacitorApp: 17fecd0e6cb23feafac7eb0939417389038b0979 + Capacitor: 679f9673fdf30597493a6362a5d5bf233d46abc2 + CapacitorApp: 0bc633b4eae40a1f32cd2834788fad3bc42da6a1 CapacitorBlobWriter: 110eeaf80611f19bf01a8a05ff3672149ed0baad - CapacitorClipboard: 45e5e25f2271f98712985d422776cdc5a779cca1 - CapacitorCommunityFileOpener: 8ae87db61961a6166cb929cc16e3cc719ea58da1 - CapacitorCordova: 70b13b8fddb6f35d8adcfe06cb5045c07f35f6de - CapacitorDevice: fc91bdb484dc0e70755e9b621cd557afe642613a - CapacitorFilesystem: 9f3e3c7fea2fff12f46dd5b07a2914f2103e4cfc - CapacitorFirebaseAuthentication: 64eaf9727b8f29f84069595ddc239c8e5365f49a - CapacitorFirebaseCrashlytics: 643d7e63836ae9608e10b9c3e40dfbae8679589e - CapacitorFirebasePerformance: 964b215796522c83767a78ad629b9581558b391a - CapacitorLocalNotifications: c58afadd159f6bc540ef9b3cbdbc82510a2bf112 - CapacitorPushNotifications: 2327900bc002f5ff49ee6d2231796d0635b4a1b0 - CapacitorShare: c6a1ebbf0114ff9e863b966cd6052678fa25d480 - CapacitorSplashScreen: dd3de3f3644710fa2a697cfb91ec262eece4d242 - CapawesomeCapacitorAppUpdate: 04f2c4b7942ccc72a86bcd553ab3c546df422838 - FirebaseABTesting: 66d2594b36d4ff6e7d3c8719802100990de05857 - FirebaseAppCheckInterop: 58db3e9494751399cf3e7b7e3e705cff71099153 - FirebaseAuth: bbe4c68f958504ba9e54aee181adbdf5b664fbc6 - FirebaseCore: 0326ec9b05fbed8f8716cddbf0e36894a13837f7 - FirebaseCoreExtension: 6394c00b887d0bebadbc7049c464aa0cbddc5d41 - FirebaseCoreInternal: bca337352024b18424a61e478460547d46c4c753 - FirebaseCrashlytics: e568d68ce89117c80cddb04073ab9018725fbb8c - FirebaseInstallations: 763814908793c0da14c18b3dcffdec71e29ed55e - FirebasePerformance: 095debad1fc8d7d73148a835fcaec9e528946166 - FirebaseRemoteConfig: e1b992a94d3674dddbcaf5d0d31a0312156ceb1c - FirebaseSessions: cd97fb07674f3906619c871eefbd260a1546c9d3 - FirebaseSharedSwift: 48076404e6e52372290d15a07d2ed1d2f1754023 + CapacitorClipboard: 756cd7e83e8d5d19b0c74f40b57517c287bd5fe2 + CapacitorCommunityFileOpener: 4e0086fac78b4ccaaf64956abe34d3443db5767a + CapacitorCordova: f48c89f96c319101cd2f0ce8a2b7449b5fb8b3dd + CapacitorDevice: 7097a1deb4224b77fd13a6e60a355d0062a5d772 + CapacitorFilesystem: 37fb3aa5c945b4539ab11c74a5c57925a302bf24 + CapacitorFirebaseAuthentication: 731483d98bf879a1c1b071c1efedd4bd5b0da6f2 + CapacitorFirebaseCrashlytics: a4c495104fbe5cb28967ae24ba1364a35d3c1173 + CapacitorFirebasePerformance: c806ce7f8270295465c050210d8e8a4ae2dc282e + CapacitorLocalNotifications: 6bac9e948b2b8852506c6d74abb2cde140250f86 + CapacitorPushNotifications: ccd797926c030acad3d5498ef452c735c90a2c89 + CapacitorShare: 591ae4693d85686ceb590db8e8b44aa014ec6490 + CapacitorSplashScreen: 250df9ef8014fac5c7c1fd231f0f8b1d8f0b5624 + CapawesomeCapacitorAppUpdate: 3c05b5c8e42f9c6a88d666093406e9336d9bfdb1 + FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe + FirebaseAppCheckInterop: 6a1757cfd4067d8e00fccd14fcc1b8fd78cfac07 + FirebaseAuth: e2ebfaf9fb4638a1c9a3b0efd17d1b90943987cd + FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16 + FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f + FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 + FirebaseCrashlytics: 34647b41e18de773717fdd348a22206f2f9bc774 + FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd + FirebasePerformance: d0ac4aa90f8c1aedeb8d0329a56e2d77d8d9e004 + FirebaseRemoteConfig: 48ef3f243742a8d72422ccfc9f986e19d7de53fd + FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d + FirebaseSessions: dbd14adac65ce996228652c1fc3a3f576bdf3ecc + FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a - GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 - GTMSessionFetcher: 8a1b34ad97ebe6f909fb8b9b77fba99943007556 + GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 nanopb: 438bc412db1928dac798aa6fd75726007be04262 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 diff --git a/package.json b/package.json index 2aa77493f1..0f9d2d39ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.16.34", + "version": "0.16.35", "author": "IDEMS International", "license": "See LICENSE", "homepage": "https://idems.international/", @@ -41,22 +41,22 @@ "@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/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/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", "@ionic-native/core": "^5.36.0", "@ionic-native/device": "^5.36.0", "@ionic-native/http": "^5.36.0", @@ -68,7 +68,7 @@ "@supabase/supabase-js": "^2.39.0", "@types/file-saver": "^2.0.7", "bootstrap-datepicker": "^1.10.0", - "capacitor-blob-writer": "^1.1.14", + "capacitor-blob-writer": "^1.1.17", "clone": "^2.1.2", "core-js": "^3.33.3", "data-models": "workspace:*", @@ -78,7 +78,7 @@ "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", "extract-math": "^1.2.3", "file-saver": "^2.0.5", "firebase": "^10.7.0", @@ -91,6 +91,7 @@ "katex": "^0.16.10", "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", @@ -122,9 +123,9 @@ "@angular/compiler": "~17.2.2", "@angular/compiler-cli": "~17.2.2", "@angular/language-service": "~17.2.2", - "@capacitor/cli": "^5.5.1", + "@capacitor/cli": "^6.0.0", "@compodoc/compodoc": "^1.1.23", - "@ionic/angular-toolkit": "^10.0.0", + "@ionic/angular-toolkit": "^11.0.1", "@ionic/cli": "^7.1.5", "@schematics/angular": "~17.0.3", "@swc/helpers": "^0.5.1", diff --git a/packages/actions/templates/app-build/template.yml b/packages/actions/templates/app-build/template.yml index 9f0d24a556..fbadc1fe19 100644 --- a/packages/actions/templates/app-build/template.yml +++ b/packages/actions/templates/app-build/template.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.17.0 - name: Cache node modules uses: actions/cache@v3 with: @@ -69,7 +69,7 @@ jobs: # Use github pages upload artifact action to compress and upload - name: Upload artifact - uses: actions/upload-pages-artifact@v1.0.8 + uses: actions/upload-pages-artifact@v3 with: path: "www/" name: ${{inputs.artifact-name}} diff --git a/packages/actions/templates/deploy-firebase/template.yml b/packages/actions/templates/deploy-firebase/template.yml index 63a7e7b79d..936f146569 100644 --- a/packages/actions/templates/deploy-firebase/template.yml +++ b/packages/actions/templates/deploy-firebase/template.yml @@ -49,7 +49,7 @@ jobs: # Extract build artifact - uses: actions/checkout@v3 - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www - name: Extract Build folder diff --git a/packages/actions/templates/pr-preview-firebase/template.yml b/packages/actions/templates/pr-preview-firebase/template.yml index d99fca980b..4a0b4e3e9f 100644 --- a/packages/actions/templates/pr-preview-firebase/template.yml +++ b/packages/actions/templates/pr-preview-firebase/template.yml @@ -50,7 +50,7 @@ jobs: # Extract build artifact - uses: actions/checkout@v3 - name: Download Build Artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: www - name: Extract Build folder diff --git a/packages/data-models/appConfig.ts b/packages/data-models/appConfig.ts index cdfa5b22cb..6b9b6781f5 100644 --- a/packages/data-models/appConfig.ts +++ b/packages/data-models/appConfig.ts @@ -1,6 +1,7 @@ /// import APP_CONFIG_GLOBALS from "./app-config/globals"; import clone from "clone"; +import { type RecursivePartial } from "shared/src/types"; import { IAppSkin } from "./skin.model"; /********************************************************************************************* @@ -114,9 +115,14 @@ const APP_HEADER_DEFAULTS = { activeRoute(location) === APP_ROUTE_DEFAULTS.home_route, }; -/** Utility function to return the active pathname without any sidebar routing e.g. /home(sidebar:alt) */ +/** + * Utility function to return the active pathname without any sidebar routing e.g. /home(sidebar:alt) + * or basename when deployed to subfolder path, e.g. /my-repo/template/home (provided by in head) + * */ const activeRoute = (location: Location) => { - return location.pathname.replace(/\(.+\)/, ""); + const baseHref = document.querySelector("base")?.getAttribute("href"); + const path = location.pathname.replace(baseHref, "/").replace(/\(.+\)/, ""); + return path; }; const APP_FOOTER_DEFAULTS: { templateName: string | null } = { @@ -217,14 +223,14 @@ const APP_CONFIG = { SERVER_SYNC_FREQUENCY_MS, TASKS, }; -// Export as a clone to avoid risk one import could alter another -export const getDefaultAppConfig = () => clone(APP_CONFIG); -export type IAppConfig = typeof APP_CONFIG; -/** A recursive version of Partial, making all properties, included nested ones, optional. - * Copied from https://stackoverflow.com/a/47914631 +/** + * Get full app config populated with default values + * Returned as an editable clone so that changes will not impact original */ -export type RecursivePartial = { - [P in keyof T]?: RecursivePartial; -}; +export const getDefaultAppConfig = (): IAppConfig => clone(APP_CONFIG); + +export type IAppConfig = typeof APP_CONFIG; + +/** Config overrides support deep-nested partials, merged with defaults at runtime */ export type IAppConfigOverride = RecursivePartial; diff --git a/packages/data-models/deployment.model.ts b/packages/data-models/deployment.model.ts index 791d38358e..53f8519181 100644 --- a/packages/data-models/deployment.model.ts +++ b/packages/data-models/deployment.model.ts @@ -1,13 +1,80 @@ import type { IGdriveEntry } from "../@idemsInternational/gdrive-tools"; -import type { IAppConfig } from "./appConfig"; +import type { IAppConfig, IAppConfigOverride } from "./appConfig"; /** Update version to force recompile next time deployment set (e.g. after default config update) */ -export const DEPLOYMENT_CONFIG_VERSION = 20240314.0; +export const DEPLOYMENT_CONFIG_VERSION = 20240914.0; -export interface IDeploymentConfig { +/** Configuration settings available to runtime application */ +export interface IDeploymentRuntimeConfig { + /** version of open-app-builder used to compile, read from builder repo package.json */ + _app_builder_version: string; + /** tag of content version provided by content git repo*/ + _content_version: string; + + api: { + /** Specify whether to enable communication with backend API (default true)*/ + enabled: boolean; + /** Name of target db for api operations. Default `plh` */ + db_name?: string; + /** + * Target endpoint for api. Default `https://apps-server.idems.international/api` + * Will be replaced when running locally as per `src\app\shared\services\server\interceptors.ts` + * */ + endpoint?: string; + }; + analytics: { + enabled: boolean; + provider: "matomo"; + endpoint: string; + siteId: number; + }; + /** Optional override of any provided constants from data-models/constants */ + app_config: IAppConfigOverride; + /** 3rd party integration for logging services */ + error_logging?: { + /** sentry/glitchtip logging dsn */ + dsn: string; + }; + /** + * Specify if using firebase for auth and crashlytics. + * Requires firebase config available through encrypted config */ + firebase: { + /** Project config as specified in firebase console (recommend loading from encrypted environment) */ + config?: { + apiKey: string; + authDomain: string; + databaseURL: string; + projectId: string; + storageBucket: string; + messagingSenderId: string; + appId: string; + measurementId: string; + }; + auth: { + /** Enables `auth` actions to allow user sign-in/out */ + enabled: boolean; + }; + crashlytics: { + /** Enables app crash reports to firebase crashlytics */ + enabled: boolean; + }; + }; /** Friendly name used to identify the deployment name */ name: string; + /** 3rd party integration for remote asset storage and sync */ + supabase: { + enabled: boolean; + url?: string; + publicApiKey?: string; + }; + web: { + /** Relative path of custom favicon asset to load from app_data assets */ + favicon_asset?: string; + }; +} +/** Deployment settings not available at runtime */ +interface IDeploymentCoreConfig { google_drive: { /** @deprecated Use `sheets_folder_ids` array instead */ sheets_folder_id?: string; @@ -41,17 +108,6 @@ export interface IDeploymentConfig { icon_asset_foreground_path?: string; icon_asset_background_path?: string; }; - api: { - /** Name of target db for api operations. Default `plh` */ - db_name?: string; - /** - * Target endpoint for api. Default `https://apps-server.idems.international/api` - * Will be replaced when running locally as per `src\app\shared\services\server\interceptors.ts` - * */ - endpoint?: string; - }; - /** Optional override of any provided constants from data-models/constants */ - app_config: IAppConfig; app_data: { /** Folder to populate processed content. Default `./app_data` */ output_path: string; @@ -60,30 +116,6 @@ export interface IDeploymentConfig { /** filter function that receives basic file info such as relativePath and size. Default `(fileEntry)=>true`*/ assets_filter_function: (fileEntry: IContentsEntry) => boolean; }; - /** - * Specify if using firebase for auth and crashlytics. - * Requires firebase config available through encrypted config */ - firebase: { - /** Project config as specified in firebase console (recommend loading from encrypted environment) */ - config?: { - apiKey: string; - authDomain: string; - databaseURL: string; - projectId: string; - storageBucket: string; - messagingSenderId: string; - appId: string; - measurementId: string; - }; - auth: { - /** Enables `auth` actions to allow user sign-in/out */ - enabled: boolean; - }; - crashlytics: { - /** Enables app crash reports to firebase crashlytics */ - enabled: boolean; - }; - }; git: { /** Url of external git repo to store content */ content_repo?: string; @@ -96,12 +128,6 @@ export interface IDeploymentConfig { /** App Store app name, e.g. "Example App" */ app_name?: string; }; - /** 3rd party integration for remote asset storage and sync */ - supabase: { - enabled: boolean; - url?: string; - publicApiKey?: string; - }; translations: { /** List of all language codes to include. Default null (includes all) */ filter_language_codes?: string[]; @@ -110,21 +136,12 @@ export interface IDeploymentConfig { /** translated string for import. Default `./app_data/translations_source/translated_strings */ translated_strings_path?: string; }; - web: { - /** Relative path of custom favicon asset to load from app_data assets */ - favicon_asset?: string; - }; workflows: { /** path to custom workflow files to include */ custom_ts_files: string[]; /** path for task working directory. Default `./tasks` */ task_cache_path: string; }; - /** 3rd party integration for logging services */ - error_logging?: { - /** sentry/glitchtip logging dsn */ - dsn: string; - }; /** track whether deployment processed from default config */ _validated: boolean; /** version number added from scripts to recompile on core changes */ @@ -141,6 +158,14 @@ interface IFlowTypeBase { status: "draft" | "released"; } +export type IDeploymentConfig = IDeploymentCoreConfig & IDeploymentRuntimeConfig; + +/** + * Generated config includes placeholders for all app_config entries to allow specific + * overrides for deeply nested properties, e.g. `app_config.NOTIFICATION_DEFAULTS.time.hour` + */ +export type IDeploymentConfigGenerated = IDeploymentConfig & { app_config: IAppConfig }; + /** Deployment with additional metadata when set as active deployment */ export interface IDeploymentConfigJson extends IDeploymentConfig { _workspace_path: string; @@ -148,9 +173,39 @@ export interface IDeploymentConfigJson extends IDeploymentConfig { _config_version: number; } +export const DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS: IDeploymentRuntimeConfig = { + _content_version: "", + _app_builder_version: "", + name: "", + api: { + enabled: true, + db_name: "plh", + endpoint: "https://apps-server.idems.international/api", + }, + analytics: { + enabled: true, + provider: "matomo", + siteId: 1, + endpoint: "https://apps-server.idems.international/analytics", + }, + app_config: {}, + firebase: { + config: null, + auth: { enabled: false }, + crashlytics: { enabled: true }, + }, + supabase: { + enabled: false, + }, + web: {}, +}; + /** Full example of just all config once merged with defaults */ -export const DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS: IDeploymentConfig = { - name: "Full Config Example", +export const DEPLOYMENT_CONFIG_DEFAULTS: IDeploymentConfig = { + ...DEPLOYMENT_RUNTIME_CONFIG_DEFAULTS, + // NOTE - app_config will be populated during config generation + app_config: {} as any, + name: "", google_drive: { assets_folder_id: "", sheets_folder_id: "", @@ -159,11 +214,6 @@ export const DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS: IDeploymentConfig = { assets_filter_function: (gdriveEntry) => true, }, android: {}, - api: { - db_name: "plh", - endpoint: "https://apps-server.idems.international/api", - }, - app_config: {} as any, // populated by `getDefaultAppConstants()`, local_drive: { assets_path: "./assets", sheets_path: "./sheets", @@ -173,21 +223,12 @@ export const DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS: IDeploymentConfig = { sheets_filter_function: (flow) => true, assets_filter_function: (fileEntry) => true, }, - firebase: { - config: null, - auth: { enabled: false }, - crashlytics: { enabled: true }, - }, ios: {}, - supabase: { - enabled: false, - }, translations: { filter_language_codes: null, source_strings_path: "./app_data/translations_source/source_strings", translated_strings_path: "./app_data/translations_source/translated_strings", }, - web: {}, workflows: { custom_ts_files: [], task_cache_path: "./tasks", diff --git a/packages/data-models/flowTypes.ts b/packages/data-models/flowTypes.ts index 9b568bfe78..dd6f2411d4 100644 --- a/packages/data-models/flowTypes.ts +++ b/packages/data-models/flowTypes.ts @@ -2,7 +2,7 @@ import type { IDataPipeOperation } from "shared"; import type { IAppConfig } from "./appConfig"; -import { IAssetEntry } from "./deployment.model"; +import type { IAssetEntry } from "./deployment.model"; /********************************************************************************************* * Base flow types @@ -287,13 +287,14 @@ export namespace FlowTypes { | "parent_point_box" | "parent_point_counter" | "pdf" + | "progress_path" | "qr_code" | "radio_button_grid" | "radio_group" | "round_button" | "select_text" | "set_default" - | "set_field" // TODO - requires global implementation (and possibly rename to set_field_default as value does not override) + | "set_field" | "set_local" | "set_variable" | "simple_checkbox" @@ -305,12 +306,13 @@ export namespace FlowTypes { | "template" | "text_area" | "text_box" + | "text_bubble" | "text" | "tile_component" | "timer" | "title" | "toggle_bar" - | "update_action_list" // update own action list + | "update_action_list" | "video" | "workshops_accordion"; @@ -419,6 +421,7 @@ export namespace FlowTypes { "share", "style", "start_tour", + "task", "task_group_set_highlighted", "toggle_field", "track_event", diff --git a/packages/data-models/functions.ts b/packages/data-models/functions.ts index 1d6940e02e..9131065e8c 100644 --- a/packages/data-models/functions.ts +++ b/packages/data-models/functions.ts @@ -71,6 +71,7 @@ export function extractDynamicFields(data: any): FlowTypes.IDynamicField | undef export function extractDynamicEvaluators( fullExpression: string ): FlowTypes.TemplateRowDynamicEvaluator[] | null { + const appConfigDefault = getDefaultAppConfig(); // match fields such as @local.someField // deeper nesting will be need to be handled after evaluation as part of JSEvaluation // (e.g. @local.somefield.nestedProperty or even !@local.@local.dynamicNested) @@ -89,7 +90,7 @@ export function extractDynamicEvaluators( type = "raw"; } // cross-check to ensure lookup matches one of the pre-defined dynamic field types (e.g. not email@domain.com) - if (!getDefaultAppConfig().DYNAMIC_PREFIXES.includes(type)) { + if (!appConfigDefault.DYNAMIC_PREFIXES.includes(type)) { return undefined; } return { fullExpression, matchedExpression, type, fieldName }; diff --git a/packages/scripts/.eslintrc.json b/packages/scripts/.eslintrc.json index 8c737ad1c6..ff2b5af3e7 100644 --- a/packages/scripts/.eslintrc.json +++ b/packages/scripts/.eslintrc.json @@ -2,5 +2,14 @@ "parserOptions": { "sourceType": "module", "ecmaVersion": "latest" - } + }, + "plugins": ["jest"], + "overrides": [ + { + "files": ["**/*.spec.ts"], + "plugins": ["jest"], + "extends": ["plugin:jest/recommended"], + "rules": {} + } + ] } diff --git a/packages/scripts/.gitignore b/packages/scripts/.gitignore index e01aa8289c..e0c60ded23 100644 --- a/packages/scripts/.gitignore +++ b/packages/scripts/.gitignore @@ -7,4 +7,5 @@ dist exec test/data/cache -test/data/output \ No newline at end of file +test/data/output +test/data/reports \ No newline at end of file diff --git a/packages/scripts/bin/app-scripts b/packages/scripts/bin/app-scripts old mode 100644 new mode 100755 diff --git a/packages/scripts/bin/app-workflow b/packages/scripts/bin/app-workflow old mode 100644 new mode 100755 diff --git a/packages/scripts/jest.config.js b/packages/scripts/jest.config.js new file mode 100644 index 0000000000..37a5b6e946 --- /dev/null +++ b/packages/scripts/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} **/ +module.exports = { + testEnvironment: "node", + transform: { + "^.+.tsx?$": ["ts-jest", {}], + }, + globalSetup: "/test/setup.ts", +}; + diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 8900e27835..eff6f9ca51 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -11,8 +11,8 @@ "start": "ts-node src/commands/index.ts", "build": "ts-node build.ts", "dev": "ts-node-dev --transpile-only --respawn --watch src src/commands/index.ts", - "test": "jasmine-ts --config=jasmine.json", - "test:watch": "nodemon --ext ts --exec 'jasmine-ts --config=jasmine.json'" + "test": "jest --runInBand --", + "test:watch": "jest --runInBand --watchAll --" }, "author": "", "license": "ISC", @@ -47,14 +47,13 @@ "@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", - "jasmine": "^3.99.0", - "jasmine-spec-reporter": "^7.0.0", - "jasmine-ts": "^0.4.0", - "mock-fs": "^5.2.0", - "nodemon": "^2.0.19", + "eslint-plugin-jest": "^28.8.0", + "jest": "^29.7.0", + "memfs": "^4.11.1", + "ts-jest": "^29.2.5", "ts-node": "^10.8.0", "ts-node-dev": "^2.0.0", "tsup": "^7.2.0" diff --git a/packages/scripts/src/commands/app-data/convert/cacheStrategy/jsonFile.spec.ts b/packages/scripts/src/commands/app-data/convert/cacheStrategy/jsonFile.spec.ts index c45bb69888..445cf8ae8d 100644 --- a/packages/scripts/src/commands/app-data/convert/cacheStrategy/jsonFile.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/cacheStrategy/jsonFile.spec.ts @@ -89,11 +89,11 @@ describe("Json File Cache", () => { // Add entry const data = Math.random(); const { entryName, filePath } = cache.add(data); - expect(existsSync(filePath)).toBeTrue(); + expect(existsSync(filePath)).toEqual(true); expect(cache.get(entryName)).toEqual(data); // Remove entry cache.remove(entryName); - expect(existsSync(filePath)).toBeFalse(); + expect(existsSync(filePath)).toEqual(false); expect(cache.get(entryName)).toBeUndefined(); }); diff --git a/packages/scripts/src/commands/app-data/convert/convert.spec.ts b/packages/scripts/src/commands/app-data/convert/convert.spec.ts index 5bcbcec9de..9a2ead8112 100644 --- a/packages/scripts/src/commands/app-data/convert/convert.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/convert.spec.ts @@ -1,59 +1,80 @@ import { AppDataConverter } from "./index"; -import path, { resolve } from "path"; +import { resolve } from "path"; -import { SCRIPTS_TEST_DATA_DIR } from "../../../paths"; -import { emptyDirSync, existsSync, readdirSync, readJSONSync } from "fs-extra"; -import { clearLogs, getLogs } from "./utils"; -import { useMockErrorLogger } from "../../../../test/helpers/utils"; +import { emptyDirSync, existsSync, readdirSync, readJSONSync, ensureDirSync } from "fs-extra"; +import { clearLogs } from "shared"; -// Folders used for tests +import { TEST_DATA_PATHS } from "../../../../test/helpers/utils"; +import { ActiveDeployment } from "../../deployment/get"; +import { IDeploymentConfigJson } from "data-models"; + +const { SHEETS_CACHE_FOLDER, SHEETS_INPUT_FOLDER, SHEETS_OUTPUT_FOLDER, TEST_DATA_DIR } = + TEST_DATA_PATHS; const paths = { - inputFolders: [path.resolve(SCRIPTS_TEST_DATA_DIR, "input", "sheets")], - outputFolder: path.resolve(SCRIPTS_TEST_DATA_DIR, "output", "sheets"), - cacheFolder: path.resolve(SCRIPTS_TEST_DATA_DIR, "cache"), + inputFolders: [resolve(SHEETS_INPUT_FOLDER, "sheets")], + outputFolder: resolve(SHEETS_OUTPUT_FOLDER, "sheets"), + cacheFolder: resolve(SHEETS_CACHE_FOLDER), + reportsFolder: resolve(TEST_DATA_DIR, "reports"), +}; + +const mockDeployment: Partial = { + app_data: { + sheets_filter_function: () => true, + assets_filter_function: () => true, + output_path: paths.outputFolder, + }, + // HACK - output reports get populated relative to workspace path so use test_data DIR + _workspace_path: TEST_DATA_DIR, }; +// HACK - avoid loading active deployment +jest.spyOn(ActiveDeployment, "get").mockReturnValue(mockDeployment as IDeploymentConfigJson); + +/** yarn workspace scripts test -t convert.spec.ts */ describe("App Data Converter", () => { let converter: AppDataConverter; beforeAll(() => { - if (existsSync(paths.outputFolder)) { - path.resolve(SCRIPTS_TEST_DATA_DIR, "output"); - } + ensureDirSync(paths.outputFolder); + emptyDirSync(paths.outputFolder); + ensureDirSync(paths.cacheFolder); + emptyDirSync(paths.cacheFolder); }); beforeEach(() => { - clearLogs(); converter = new AppDataConverter(paths); }); - afterAll(() => { - emptyDirSync(path.resolve(SCRIPTS_TEST_DATA_DIR, "output")); - }); + it("Uses child caches", async () => { + await converter.run(); const cacheFolders = readdirSync(paths.cacheFolder); + // expect contents file and cached conversions expect(cacheFolders.length).toBeGreaterThan(1); }); it("Clears child caches on version change", async () => { - const updatedConverter = new AppDataConverter(paths, { version: -1 }); + const updatedConverter = new AppDataConverter(paths, { version: new Date().getTime() }); + // no need to run the converter, simply creating should clear the cache const cacheFolders = readdirSync(paths.cacheFolder); - expect(cacheFolders.length).toEqual(1); // only contents file + expect(cacheFolders).toHaveLength(1); // only contents file }); it("Processes test_input xlsx without error", async () => { - await converter.run(); - const errorLogs = getLogs("error"); - expect(errorLogs.length).toEqual(0); + const { errors, result } = await converter.run(); + expect(errors).toHaveLength(0); + expect(Object.values(result).length).toBeGreaterThan(0); }); it("Populates output to folder by data type", async () => { await converter.run(); const outputFolders = readdirSync(paths.outputFolder); expect(outputFolders).toEqual(["data_list", "data_pipe", "template"]); }); + it("Generates summary conversion reports", async () => { + await converter.run(); + const reports = readdirSync(paths.reportsFolder); + expect(reports).toEqual(["summary.json", "summary.md"]); + }); it("Supports input from multiple source folders", async () => { const multipleSourceConverter = new AppDataConverter({ ...paths, - inputFolders: [ - ...paths.inputFolders, - path.resolve(SCRIPTS_TEST_DATA_DIR, "input", "sheets_additional"), - ], + inputFolders: [...paths.inputFolders, resolve(SHEETS_INPUT_FOLDER, "sheets_additional")], }); await multipleSourceConverter.run(); const replaceDataListPath = resolve( @@ -70,26 +91,24 @@ describe("App Data Converter", () => { // Folders used for error tests const errorPaths = { - inputFolders: [path.resolve(SCRIPTS_TEST_DATA_DIR, "input", "errorChecking")], - outputFolder: path.resolve(SCRIPTS_TEST_DATA_DIR, "output", "errorChecking"), - cacheFolder: path.resolve(SCRIPTS_TEST_DATA_DIR, "cache"), + inputFolders: [resolve(SHEETS_INPUT_FOLDER, "errorChecking")], + outputFolder: resolve(SHEETS_OUTPUT_FOLDER, "errorChecking"), + cacheFolder: resolve(SHEETS_CACHE_FOLDER), }; describe("App Data Converter - Error Checking", () => { let errorConverter: AppDataConverter; - let errorLogger: jasmine.Spy; beforeAll(() => { if (existsSync(paths.outputFolder)) { emptyDirSync(paths.outputFolder); } }); beforeEach(() => { - errorLogger = useMockErrorLogger(); errorConverter = new AppDataConverter(errorPaths); }); it("Tracks conversion errors", async () => { - await errorConverter.run(); - const loggerErrors = getLogs("error"); - const errorMessages = loggerErrors.map((err) => err.message); + clearLogs(true); + const { errors } = await errorConverter.run(); + const errorMessages = errors.map((err) => err.message); expect(errorMessages).toEqual([ "Duplicate flow name", "No parser available for flow_type: test_invalid_type", diff --git a/packages/scripts/src/commands/app-data/convert/index.ts b/packages/scripts/src/commands/app-data/convert/index.ts index eb54adcfe2..dd9f52b3e3 100644 --- a/packages/scripts/src/commands/app-data/convert/index.ts +++ b/packages/scripts/src/commands/app-data/convert/index.ts @@ -11,7 +11,6 @@ import { JsonFileCache } from "./cacheStrategy/jsonFile"; import { generateFolderFlatMap, createChildFileLogger, - logSheetsSummary, getLogs, Logger, getLogFiles, @@ -19,6 +18,7 @@ import { standardiseNewlines, } from "./utils"; import { FlowParserProcessor } from "./processors/flowParser/flowParser"; +import { ReportGenerator } from "./report/report"; /*************************************************************************************** * CLI @@ -69,7 +69,10 @@ export class AppDataConverter { cache: JsonFileCache; - constructor(private options: IConverterOptions, testOverrides: Partial = {}) { + constructor( + private options: IConverterOptions, + testOverrides: Partial = {} + ) { console.log(chalk.yellow("App Data Convert")); // optional overrides, used for tests if (testOverrides.version) this.version = testOverrides.version; @@ -127,6 +130,9 @@ export class AppDataConverter { processor.logger = this.logger; const jsonFlows = Object.values(combinedOutputsHashmap); const result = (await processor.process(jsonFlows)) as IParsedWorkbookData; + await new ReportGenerator(this.activeDeployment).process(result); + + // TODO - write to disk and log const { errors, warnings } = this.logOutputs(result); return { result, errors, warnings }; } @@ -149,10 +155,9 @@ export class AppDataConverter { /** Create log of total warnings and errors */ private logOutputs(result: IParsedWorkbookData) { this.writeOutputJsons(result); - logSheetsSummary(result); - const warnings = getLogs("warning"); + const warnings = getLogs("warn"); if (warnings.length > 0) { - const warningLogFile = getLogFiles().warning; + const warningLogFile = getLogFiles().warn; logWarning({ msg1: `Completed with ${warnings.length} warnings`, msg2: warningLogFile, diff --git a/packages/scripts/src/commands/app-data/convert/processors/base.spec.ts b/packages/scripts/src/commands/app-data/convert/processors/base.spec.ts index 20485e66d9..49d87018a0 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/base.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/base.spec.ts @@ -1,14 +1,7 @@ -import path from "path"; import BaseProcessor from "./base"; -import { SCRIPTS_WORKSPACE_PATH } from "../../../../paths"; import { clearLogs, getLogs } from "../utils"; -const testDataDir = path.resolve(SCRIPTS_WORKSPACE_PATH, "test", "data"); -const paths = { - SHEETS_CACHE_FOLDER: path.resolve(testDataDir, "cache"), - SHEETS_INPUT_FOLDER: path.resolve(testDataDir, "input"), - SHEETS_OUTPUT_FOLDER: path.resolve(testDataDir, "output"), -}; +import { TEST_DATA_PATHS } from "../../../../../test/helpers/utils"; const testData = [ { @@ -23,9 +16,15 @@ class TestProcessor extends BaseProcessor { } } let processor: TestProcessor; + +/** yarn workspace scripts test -t base.spec.ts */ describe("Base Processor", () => { beforeAll(() => { - processor = new TestProcessor({ namespace: "BaseProcessor", paths }); + processor = new TestProcessor({ + namespace: "BaseProcessor", + paths: TEST_DATA_PATHS, + cacheVersion: new Date().getTime(), + }); processor.cache.clear(); }); afterAll(() => { @@ -58,7 +57,11 @@ describe("Deferred Processor", () => { } let deferredProcessor: DeferredProcessor; beforeEach(() => { - deferredProcessor = new DeferredProcessor({ namespace: "BaseProcessor", paths }); + deferredProcessor = new DeferredProcessor({ + namespace: "BaseProcessor", + paths: TEST_DATA_PATHS, + cacheVersion: new Date().getTime(), + }); deferredProcessor.cache.clear(); }); afterAll(() => { diff --git a/packages/scripts/src/commands/app-data/convert/processors/flowParser/flowParser.spec.ts b/packages/scripts/src/commands/app-data/convert/processors/flowParser/flowParser.spec.ts index c208697123..4112af899d 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/flowParser/flowParser.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/flowParser/flowParser.spec.ts @@ -1,19 +1,7 @@ -import path from "path"; import { FlowTypes } from "data-models"; -import { SCRIPTS_WORKSPACE_PATH } from "../../../../../paths"; import { clearLogs, getLogs } from "../../utils"; import { FlowParserProcessor } from "./flowParser"; - -const testDataDir = path.resolve(SCRIPTS_WORKSPACE_PATH, "test", "data"); -const paths = { - SHEETS_CACHE_FOLDER: path.resolve(testDataDir, "cache"), - SHEETS_INPUT_FOLDER: path.resolve(testDataDir, "input"), - SHEETS_OUTPUT_FOLDER: path.resolve(testDataDir, "output"), -}; -// Export method to allow use in parser-specific tests (to test on multiple instances of a flow type) -export function getTestFlowParserProcessor() { - return new FlowParserProcessor(paths); -} +import { TEST_DATA_PATHS } from "../../../../../../test/helpers/utils"; // NOTE - inputs are just to test general structure and not run actual parser code const testInputs: FlowTypes.FlowTypeWithData[] = [ @@ -48,7 +36,7 @@ const testInputs: FlowTypes.FlowTypeWithData[] = [ let processor: FlowParserProcessor; describe("FlowParser Processor", () => { beforeAll(() => { - processor = getTestFlowParserProcessor(); + processor = new FlowParserProcessor(TEST_DATA_PATHS); processor.cache.clear(); }); beforeEach(() => { @@ -77,7 +65,7 @@ describe("FlowParser Processor", () => { rows: null, }; await processor.process([brokenFlow]); - const errorLogs = getLogs("error", "Template parse error"); + const errorLogs = getLogs("error").filter(({ message }) => message === "Template parse error"); expect(errorLogs).toEqual([ { source: "flowParser", @@ -113,7 +101,7 @@ describe("FlowParser Processor", () => { /** Additional tests for data pipe integration */ describe("FlowParser Processor - Data Pipes", () => { beforeAll(() => { - processor = getTestFlowParserProcessor(); + processor = new FlowParserProcessor(TEST_DATA_PATHS); processor.cache.clear(); }); beforeEach(() => { diff --git a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/data_list.parser.spec.ts b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/data_list.parser.spec.ts index 96da0dbd11..ec33ef481e 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/data_list.parser.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/data_list.parser.spec.ts @@ -1,5 +1,6 @@ import { DataListParser } from "."; -import { getTestFlowParserProcessor } from "../flowParser.spec"; +import { TEST_DATA_PATHS } from "../../../../../../../test/helpers/utils"; +import { FlowParserProcessor } from "../flowParser"; const testFlow = { flow_type: "data_list", @@ -15,6 +16,7 @@ const testFlow = { ], }; +/** yarn workspace scripts test -t data_list.parser.spec.ts **/ describe("data_list Parser (single)", () => { let outputRows: any[]; beforeAll(() => { @@ -44,7 +46,7 @@ describe("data_list Parser (single)", () => { }); describe("data_list Parser (multiple)", () => { - const parser = getTestFlowParserProcessor(); + const parser = new FlowParserProcessor(TEST_DATA_PATHS); beforeAll(() => { parser.cache.clear(); }); diff --git a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.spec.ts b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.spec.ts index d3b268069b..4f88afdb82 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.spec.ts @@ -1,3 +1,134 @@ -describe("default Parser", () => { - it("TODO - add tests", () => expect(true).toEqual(true)); +import { DefaultParser } from "./default.parser"; +import { FlowTypes } from "data-models"; + +/** + * yarn workspace scripts test -t default.parser.spec.ts + * + * TODO - add tests for rest of functionality, e.g. `@default` syntax, translated fields, + * nested group extract, special field types, `@row` self-reference, metadata fields etc. + */ +describe("Default Parser", () => { + const parser = new DefaultParser({ processedFlowHashmap: {} } as any); + it("Cleans field values - handles strings consisting only of whitespace", () => { + const output = parser.run({ + flow_name: "test_flow_input_empty", + flow_type: "data_list", + rows: [ + { + type: "text", + name: "empty", + value: "", + }, + { + type: "text", + name: "tab_empty", + value: "\t\t", + }, + { + type: "text", + name: "newline_empty", + value: "\n\n", + }, + { + type: "text", + name: "return_empty", + value: "\r\r", + }, + { + type: "text", + name: "newline_return_empty", + value: "\r\n\n\r", + }, + ], + }) as FlowTypes.FlowTypeWithData; + // not possible to use the actual objects as the function used is not exported + expect(output.rows).toEqual([ + { + type: "text", + name: "empty", + value: "", + }, + { + type: "text", + name: "tab_empty", + value: "", + }, + { + type: "text", + name: "newline_empty", + value: "", + }, + { + type: "text", + name: "return_empty", + value: "", + }, + { + type: "text", + name: "newline_return_empty", + value: "", + }, + ]); + }); + + it("Cleans field values - trims whitespace from beginning and end of strings", () => { + const output = parser.run({ + flow_name: "test_flow_input_nonempty", + flow_type: "data_list", + rows: [ + { + type: "text", + name: "spaces", + value: "this is a test string with spaces", + }, + { + type: "text", + name: "tab_and_spaces", + value: "\tthis is a test string with spaces and tabs\t", + }, + { + type: "text", + name: "newline_not_empty", + value: "Cats and dogs are the best pets\nTrue?", + }, + { + type: "text", + name: "newline_return_not_empty", + value: "Hello,\rI like dogs...\but not cats at all!\r", + }, + { + type: "text", + name: "return_not_empty", + value: "\tThis is a test string with spaces and tabs\t", + }, + ], + }) as FlowTypes.FlowTypeWithData; + expect(output.rows).toEqual([ + { + type: "text", + name: "spaces", + value: "this is a test string with spaces", + }, + { + type: "text", + name: "tab_and_spaces", + value: "this is a test string with spaces and tabs", + }, + { + type: "text", + name: "newline_not_empty", + value: "Cats and dogs are the best pets\nTrue?", + }, + { + type: "text", + name: "newline_return_not_empty", + value: "Hello,\rI like dogs...\but not cats at all!", + }, + { + type: "text", + name: "return_not_empty", + value: "This is a test string with spaces and tabs", + }, + ]); + }); }); diff --git a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.ts b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.ts index 30a8786a10..4394097e64 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/default.parser.ts @@ -36,6 +36,7 @@ export class DefaultParser< /** Default function to call a start the process of parsing rows */ public run(flow: FlowTypes.FlowTypeWithData): FlowTypes.FlowTypeWithData { this.flow = JSON.parse(JSON.stringify(flow)); + this.queue = flow.rows; const processedRows = []; // If first row specifies default values extract them and remove row from queue @@ -285,6 +286,11 @@ class RowProcessor { if (typeof this.row[field] === "string") { // remove whitespace this.row[field] = this.row[field].trim(); + + // replace any strings that consist only of whitespace with the empty string + if (!this.row[field].match(/\S/)) { + this.row[field] = ""; + } } }); } diff --git a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.ts b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.ts index ec363dff6c..fb28b296d0 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/flowParser/parsers/template.parser.ts @@ -9,7 +9,7 @@ import { } from "../../../utils"; export class TemplateParser extends DefaultParser { - postProcessRow(row: FlowTypes.TemplateRow, rowNumber = 1, nestedPath?: string) { + public override postProcessRow(row: FlowTypes.TemplateRow, rowNumber = 1, nestedPath?: string) { // remove empty rows if (Object.keys(row).length === 0) { return; @@ -72,7 +72,7 @@ export class TemplateParser extends DefaultParser { return row; } - public postProcessFlows(flows: FlowTypes.FlowTypeWithData[]) { + public override postProcessFlows(flows: FlowTypes.FlowTypeWithData[]) { const flowsWithOverrides = assignFlowOverrides(flows); return flowsWithOverrides; } diff --git a/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.spec.ts b/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.spec.ts index 4d3d17a501..fb1e1c0262 100644 --- a/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/processors/xlsxWorkbook.spec.ts @@ -32,7 +32,7 @@ describe("XLSX Workbook Processor", () => { // an array of arrays it("Converts XLSXs to array of JSON sheet arrays", async () => { const outputs = await processor.process(testInputs); - expect(Array.isArray(outputs)).toBeTrue(); + expect(Array.isArray(outputs)).toEqual(true); expect(outputs.length).toEqual(testInputs.length); // each entry may contain multiple sheets from workbook const testInputsheets = outputs[0]; diff --git a/packages/scripts/src/commands/app-data/convert/report/report.ts b/packages/scripts/src/commands/app-data/convert/report/report.ts new file mode 100644 index 0000000000..b5651393e5 --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/report.ts @@ -0,0 +1,63 @@ +import chalk from "chalk"; +import { IDeploymentConfigJson } from "data-models"; +import { writeFile, ensureDir, emptyDir } from "fs-extra"; +import { resolve, dirname } from "path"; +import { logOutput } from "shared"; + +import { IParsedWorkbookData } from "../types"; +import { IReport } from "./report.types"; +import { generateMarkdownTable } from "./report.utils"; +import { FlowByTypeReport } from "./reporters/flows-by-type"; +import { TemplateSummaryReport } from "./reporters/template-summary"; + +/** + * Create summary reports based on converted app data + * Individual reports are created by child reporters, with outputs stored in both + * json and markdown formats for easier interpretation + * + * Run on existing data via `yarn workflow sync_sheets --skip-download` + **/ +export class ReportGenerator { + constructor(private deployment: IDeploymentConfigJson) {} + + public async process(data: IParsedWorkbookData) { + const { template_actions, template_components } = await new TemplateSummaryReport().process( + data + ); + const { flows_by_type } = await new FlowByTypeReport().process(data); + const outputReports = { template_actions, template_components, flows_by_type }; + await this.writeOutputs(outputReports); + } + + private async writeOutputs(reports: Record) { + const outputDir = resolve(this.deployment._workspace_path, "reports"); + await ensureDir(dirname(outputDir)); + await emptyDir(outputDir); + await this.writeOutputJson(reports, resolve(outputDir, "summary.json")); + await this.writeOutputMarkdown(reports, resolve(outputDir, "summary.md")); + logOutput({ msg1: "Reports Generated", msg2: outputDir }); + // repeat log in case boxed output broken + console.log(chalk.gray(outputDir)); + } + + private async writeOutputJson(reports: Record, target: string) { + const output: Record = {}; + for (const [key, { data }] of Object.entries(reports)) { + output[key] = data; + } + await writeFile(target, JSON.stringify(output, null, 2)); + } + + private async writeOutputMarkdown(reports: Record, target: string) { + const contents = ["# Summary"]; + for (const report of Object.values(reports)) { + if (report.type === "table") { + contents.push(""); + contents.push(`## ${report.title}`); + const mdTable = generateMarkdownTable(report.data); + contents.push(mdTable); + } + } + await writeFile(target, contents.join("\n")); + } +} diff --git a/packages/scripts/src/commands/app-data/convert/report/report.types.ts b/packages/scripts/src/commands/app-data/convert/report/report.types.ts new file mode 100644 index 0000000000..df3a494d27 --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/report.types.ts @@ -0,0 +1,19 @@ +// Use union types to allow strong typing by report display type +export type IReport = IReportTable | IReportText; + +interface IReportBase { + /** Reporting level, default "info" (future will include warnings/recommendations). */ + level: "info"; + /** Title to display on top of report */ + title: string; +} + +export interface IReportTable extends IReportBase { + data: Record[]; + type: "table"; +} + +interface IReportText extends IReportBase { + data: string; + type: "text"; +} diff --git a/packages/scripts/src/commands/app-data/convert/report/report.utils.spec.ts b/packages/scripts/src/commands/app-data/convert/report/report.utils.spec.ts new file mode 100644 index 0000000000..5a0fac5803 --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/report.utils.spec.ts @@ -0,0 +1,41 @@ +import { generateMarkdownTable } from "./report.utils"; + +/** yarn workspace scripts test -t report.utils.spec.ts */ +describe("report utils", () => { + it("generateMarkdownTable", () => { + const res = generateMarkdownTable([ + { key_1: "value_1a", key_2: "value_1b" }, + { key_1: "value_2a", key_2: "value_2b" }, + ]); + expect(res).toEqual( + `| key_1 | key_2 |\n| --- | --- |\n| value_1a | value_1b |\n| value_2a | value_2b |` + /* When formatted output in form: + + | key_1 | key_2 | + | --- | --- | + | value_1a | value_1b | + | value_2a | value_2b | + */ + ); + }); + + it("generateMarkdownTable with named columns", () => { + const res = generateMarkdownTable( + [ + { key_1: "value_1a", key_2: "value_1b" }, + { key_1: "value_2a", key_2: "value_2b" }, + ], + ["key_1"] + ); + expect(res).toEqual( + `| key_1 |\n| --- |\n| value_1a |\n| value_2a |` + /* When formatted output in form: + + | key_1 | + | --- | + | value_1a | + | value_2a | + */ + ); + }); +}); diff --git a/packages/scripts/src/commands/app-data/convert/report/report.utils.ts b/packages/scripts/src/commands/app-data/convert/report/report.utils.ts new file mode 100644 index 0000000000..5d39c52681 --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/report.utils.ts @@ -0,0 +1,25 @@ +/** + * Convert an array to a basic markdown-formatted table + * @example + * ``` + * generateMarkdownTable([{key_1:'value_1', key_2:'value_2'}]) + * // output + * | key_1 | key_2 | + * | --- | --- | + * | value_1 | value_2 | + * ``` + */ +export function generateMarkdownTable(data: Record[], columns?: string[]) { + // infer columns from data if not provided + if (!columns) { + columns = Object.keys(data[0] || {}); + } + const rows: string[][] = []; + rows.push(columns); + rows.push(columns.map(() => "---")); + for (const el of data) { + rows.push(columns.map((c) => el[c])); + } + const rowStrings: string[] = rows.map((r) => `| ${r.join(" | ")} |`); + return rowStrings.join("\n"); +} diff --git a/packages/scripts/src/commands/app-data/convert/report/reporters/flows-by-type.spec.ts b/packages/scripts/src/commands/app-data/convert/report/reporters/flows-by-type.spec.ts new file mode 100644 index 0000000000..c7bbcccac9 --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/reporters/flows-by-type.spec.ts @@ -0,0 +1,40 @@ +import { IParsedWorkbookData } from "../../types"; +import { FlowByTypeReport } from "./flows-by-type"; + +const MOCK_WORKBOOK_DATA: IParsedWorkbookData = { + template: [ + { + flow_type: "template", + flow_name: "mock_template_1", + rows: [], + }, + { + flow_type: "data_list", + flow_name: "mock_data_list_1", + rows: [], + }, + { + flow_type: "data_list", + flow_subtype: "mock_subtype", + flow_name: "mock_data_list_2", + rows: [], + }, + { + flow_type: "data_list", + flow_name: "mock_data_list_3", + rows: [], + }, + ], +}; + +/** yarn workspace scripts test -t flows-by-type.spec.ts */ +describe("Flows By Type Report", () => { + it("Enumerates flows by type and subtype", async () => { + const { flows_by_type } = await new FlowByTypeReport().process(MOCK_WORKBOOK_DATA); + expect(flows_by_type.data).toEqual([ + { type: "data_list", subtype: null, total: 2 }, + { type: "data_list", subtype: "mock_subtype", total: 1 }, + { type: "template", subtype: null, total: 1 }, + ]); + }); +}); diff --git a/packages/scripts/src/commands/app-data/convert/report/reporters/flows-by-type.ts b/packages/scripts/src/commands/app-data/convert/report/reporters/flows-by-type.ts new file mode 100644 index 0000000000..36304256a6 --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/reporters/flows-by-type.ts @@ -0,0 +1,42 @@ +import { IParsedWorkbookData } from "../../types"; +import { IReportTable } from "../report.types"; + +interface IReportData { + type: string; + subtype: string; + total: number; +} + +interface IFlowByTypeReport extends IReportTable { + data: IReportData[]; +} + +/** Generate a list of all flows by type and subtype */ +export class FlowByTypeReport { + public async process(data: IParsedWorkbookData) { + const countBySubtype = {}; + Object.values(data).forEach((flows) => { + flows.forEach((flow) => { + let type = flow.flow_type; + if (flow.flow_subtype) type += `.${flow.flow_subtype}`; + if (!countBySubtype[type]) countBySubtype[type] = 0; + countBySubtype[type]++; + }); + }); + const summary: IReportData[] = Object.keys(countBySubtype) + .sort() + .map((key) => { + const [type, subtype] = key.split("."); + return { type, subtype: subtype || null, total: countBySubtype[key] }; + }); + + const flows_by_type: IFlowByTypeReport = { + type: "table", + title: "Flows By Type", + level: "info", + data: summary, + }; + + return { flows_by_type }; + } +} diff --git a/packages/scripts/src/commands/app-data/convert/report/reporters/template-summary.spec.ts b/packages/scripts/src/commands/app-data/convert/report/reporters/template-summary.spec.ts new file mode 100644 index 0000000000..ede3840a7d --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/reporters/template-summary.spec.ts @@ -0,0 +1,68 @@ +import { FlowTypes } from "data-models"; +import { IParsedWorkbookData } from "../../types"; +import { TemplateSummaryReport } from "./template-summary"; + +const MOCK_ROWS_1: FlowTypes.TemplateRow[] = [ + { + type: "button", + _nested_name: "", + name: "", + action_list: [ + { action_id: "share", args: [], trigger: "click" }, + { action_id: "audio_play", args: [], trigger: "click" }, + ], + }, + { + type: "text", + _nested_name: "", + name: "", + action_list: [ + { action_id: "emit", args: ["force_reprocess"], trigger: "click" }, + { action_id: "emit", args: ["completed"], trigger: "click" }, + ], + }, +]; +const MOCK_ROWS_2: FlowTypes.TemplateRow[] = [ + { + type: "button", + _nested_name: "", + name: "", + action_list: [{ action_id: "share", args: [], trigger: "click" }], + }, +]; + +const MOCK_WORKBOOK_DATA: IParsedWorkbookData = { + template: [ + { + flow_type: "template", + flow_name: "mock_template_1", + rows: MOCK_ROWS_1, + }, + { + flow_type: "template", + flow_name: "mock_template_2", + rows: MOCK_ROWS_2, + }, + ], +}; + +/** yarn workspace scripts test -t template-summary.spec.ts */ +describe("Template Summary Report", () => { + it("Enumerates component references", async () => { + const { template_components } = await new TemplateSummaryReport().process(MOCK_WORKBOOK_DATA); + expect(template_components.data).toEqual([ + { type: "button", count: 2 }, + { type: "text", count: 1 }, + ]); + }); + it("Enumerates action references", async () => { + const { template_actions } = await new TemplateSummaryReport().process(MOCK_WORKBOOK_DATA); + // NOTE - emit actions should be enumerated by subtype from args + expect(template_actions.data).toEqual([ + { type: "audio_play", count: 1 }, + { type: "emit: completed", count: 1 }, + { type: "emit: force_reprocess", count: 1 }, + { type: "share", count: 2 }, + ]); + }); +}); diff --git a/packages/scripts/src/commands/app-data/convert/report/reporters/template-summary.ts b/packages/scripts/src/commands/app-data/convert/report/reporters/template-summary.ts new file mode 100644 index 0000000000..c868ebb03d --- /dev/null +++ b/packages/scripts/src/commands/app-data/convert/report/reporters/template-summary.ts @@ -0,0 +1,68 @@ +import { FlowTypes } from "data-models"; +import { sortJsonKeys } from "shared/src/utils"; + +import { IParsedWorkbookData } from "../../types"; +import { IReportTable } from "../report.types"; + +interface IReportData { + type: string; + count: number; +} + +interface ITemplateSummaryReport extends IReportTable { + data: IReportData[]; +} + +interface ITemplateSummary { + components: Record; + actions: Record; +} + +/** + * Generate a list of all components and action types used within templates + * This will later be used to develop a list of features required + */ +export class TemplateSummaryReport { + private summary: ITemplateSummary = { actions: {}, components: {} }; + + public async process(data: IParsedWorkbookData) { + // TODO - could also consider components or actions referenced from data_lists + for (const flow of data.template || []) { + for (const row of flow.rows) { + const { action_list = [], type } = row as FlowTypes.TemplateRow; + for (const action of action_list) { + let { action_id, args } = action; + // HACK -include emit type actions + if (action_id === "emit") { + action_id += `: ${args[0]}`; + } + this.summary.actions[action_id] ??= { count: 0 }; + this.summary.actions[action_id].count++; + } + this.summary.components[type] ??= { count: 0 }; + this.summary.components[type].count++; + } + } + + const template_components: ITemplateSummaryReport = { + type: "table", + title: "Components", + level: "info", + data: this.getReportData(this.summary.components), + }; + const template_actions: ITemplateSummaryReport = { + type: "table", + title: "Actions", + level: "info", + data: this.getReportData(this.summary.actions), + }; + + return { template_components, template_actions }; + } + + /** Convert type records to array for report */ + private getReportData(data: Record): IReportData[] { + const sorted = sortJsonKeys(data); + return Object.entries(sorted).map(([type, { count }]) => ({ type, count })); + } +} diff --git a/packages/scripts/src/commands/app-data/convert/utils/app-data-override.utils.spec.ts b/packages/scripts/src/commands/app-data/convert/utils/app-data-override.utils.spec.ts index 0deb9a73cf..3b0b93731d 100644 --- a/packages/scripts/src/commands/app-data/convert/utils/app-data-override.utils.spec.ts +++ b/packages/scripts/src/commands/app-data/convert/utils/app-data-override.utils.spec.ts @@ -1,5 +1,5 @@ import { FlowTypes } from "data-models"; -import { useMockWarningLogger } from "../../../../../test/helpers/utils"; +import { useMockLogger } from "../../../../../test/helpers/utils"; import { assignFlowOverrides } from "./app-data-override.utils"; const TEST_INPUTS: FlowTypes.FlowTypeWithData[] = [ @@ -24,6 +24,7 @@ const TEST_INPUTS: FlowTypes.FlowTypeWithData[] = [ }, ]; +/** yarn workspace scripts test -t app-data-override.utils.spec.ts */ describe("App Data Override", () => { it("Assigns flow override mapping", () => { const output = assignFlowOverrides(TEST_INPUTS); @@ -36,7 +37,7 @@ describe("App Data Override", () => { }); it("Logs warning on missing override target", () => { - const warningLogger = useMockWarningLogger(); + const loggerSpy = useMockLogger(true); assignFlowOverrides([ ...TEST_INPUTS, { @@ -47,7 +48,8 @@ describe("App Data Override", () => { override_condition: "test_condition_3", }, ]); - expect(warningLogger).toHaveBeenCalledOnceWith({ + expect(loggerSpy.warning).toHaveBeenCalledTimes(1); + expect(loggerSpy.warning).toHaveBeenCalledWith({ msg1: "Override target does not exist: missing_list", msg2: "override_invalid", }); diff --git a/packages/scripts/src/commands/app-data/convert/utils/index.ts b/packages/scripts/src/commands/app-data/convert/utils/index.ts index e48d790d54..3265a8f1e5 100644 --- a/packages/scripts/src/commands/app-data/convert/utils/index.ts +++ b/packages/scripts/src/commands/app-data/convert/utils/index.ts @@ -3,8 +3,6 @@ export * from "./app-data-condition.utils"; export * from "./app-data-override.utils"; export * from "./app-data-string.utils"; -export * from "./logging"; - // re-export some shared utils for ease of import // TODO - should refactor source code to import from shared export { diff --git a/packages/scripts/src/commands/app-data/convert/utils/logging.ts b/packages/scripts/src/commands/app-data/convert/utils/logging.ts deleted file mode 100644 index 91a2485fc9..0000000000 --- a/packages/scripts/src/commands/app-data/convert/utils/logging.ts +++ /dev/null @@ -1,46 +0,0 @@ -import chalk from "chalk"; - -import { IParsedWorkbookData } from "../types"; - -/** Collate totals of flows by subtype and log */ -export function logSheetsSummary(data: IParsedWorkbookData) { - const countBySubtype = {}; - Object.values(data).forEach((flows) => { - flows.forEach((flow) => { - let type = flow.flow_type; - if (flow.flow_subtype) type += `.${flow.flow_subtype}`; - if (!countBySubtype[type]) countBySubtype[type] = 0; - countBySubtype[type]++; - }); - }); - const logOutput = Object.keys(countBySubtype) - .sort() - .map((key) => { - const [type, subtype] = key.split("."); - return { type, subtype: subtype || null, total: countBySubtype[key] }; - }); - console.log("\nSheet Summary"); - console.table(logOutput); -} - -export function logCacheActionsSummary(actions: any) { - // log summary - const summary = {}; - Object.entries(actions).forEach(([key, value]) => (summary[key] = value.length)); - console.log("\nFile Summary\n", summary); -} - -export function logSheetErrorSummary(warnings: any[], errors: any[]) { - if (warnings.length > 0) { - console.log(chalk.red(warnings.length, "warnings")); - for (const warning of warnings) { - console.log(warning); - } - } - if (errors.length > 0) { - console.log(chalk.red(errors.length, "errors")); - for (const err of errors) { - console.log(err); - } - } -} diff --git a/packages/scripts/src/commands/app-data/postProcess/assets.spec.ts b/packages/scripts/src/commands/app-data/postProcess/assets.spec.ts index 7a8f0d5f3e..9d0f624495 100644 --- a/packages/scripts/src/commands/app-data/postProcess/assets.spec.ts +++ b/packages/scripts/src/commands/app-data/postProcess/assets.spec.ts @@ -2,16 +2,19 @@ import { createHash } from "crypto"; import { AssetsPostProcessor } from "./assets"; import type { IDeploymentConfigJson } from "../../deployment/common"; -import type { RecursivePartial } from "data-models/appConfig"; +import { type RecursivePartial } from "shared/src/types"; -import fs, { readJsonSync, readdirSync } from "fs-extra"; -import mockFs from "mock-fs"; +import { readJsonSync, readdirSync, statSync, existsSync } from "fs-extra"; +import { vol } from "memfs"; // Use default imports to allow spying on functions and replacing with mock methods import { ActiveDeployment } from "../../deployment/get"; -import path, { resolve } from "path"; +import { resolve } from "path"; import { IAssetEntryHashmap } from "data-models/deployment.model"; -import { useMockErrorLogger } from "../../../../test/helpers/utils"; +import { useMockLogger } from "../../../../test/helpers/utils"; + +// Mock all fs calls to use memfs implementation +jest.mock("fs", () => require("memfs")); /** Mock file system folders for use in tests */ const mockDirs = { @@ -23,19 +26,13 @@ const { file: mockFile, entry: mockFileEntry } = createMockFile(); // create moc /** Parse the contents.json file populated to the app assets folder and return */ function readAppAssetContents() { - const contentsPath = path.resolve(mockDirs.appAssets, "contents.json"); + const contentsPath = resolve(mockDirs.appAssets, "contents.json"); return readJsonSync(contentsPath) as IAssetEntryHashmap; } /** Create mock entries on file system corresponding to local assets folder */ -function mockLocalAssets(assets: Record) { - return mockFs({ - mock: { - local: { - assets, - }, - }, - }); +function mockLocalAssets(assets: Record = {}) { + vol.fromNestedJSON(assets, mockDirs.localAssets); } function createMockFile(size_kb: number = 1024) { @@ -44,33 +41,33 @@ function createMockFile(size_kb: number = 1024) { return { file, entry }; } +/** yarn workspace scripts test -t assets.spec.ts */ describe("Assets PostProcess", () => { - /** Initial setup */ - // replace prettier codeTidying method - // replace `Logger` function with created spy method - beforeAll(() => {}); // Populate a fake file system before each test. This will automatically be called for any fs operations // Restore regular file functionality after each test. beforeEach(() => { - mockLocalAssets({}); + mockLocalAssets(); }); afterEach(() => { - mockFs.restore(); + vol.reset(); }); /** Mock setup testing (can be removed once working consistenctly) */ it("mocks file system for testing", () => { mockLocalAssets({ folder: { "file.jpg": mockFile } }); - const testFilePath = path.resolve(mockDirs.localAssets, "folder", "file.jpg"); - expect(fs.statSync(testFilePath).size).toEqual(1 * 1024 * 1024); + const testFilePath = resolve(mockDirs.localAssets, "folder", "file.jpg"); + console.log({ testFilePath }); + console.log(existsSync(testFilePath)); + expect(existsSync(testFilePath)).toEqual(true); + expect(statSync(testFilePath).size).toEqual(1 * 1024 * 1024); }); /** Main tests */ it("Copies assets from local to app", () => { mockLocalAssets({ folder: { "file.jpg": mockFile } }); runAssetsPostProcessor(); - const testFilePath = path.resolve(mockDirs.appAssets, "folder", "file.jpg"); - expect(fs.statSync(testFilePath).size).toEqual(1 * 1024 * 1024); + const testFilePath = resolve(mockDirs.appAssets, "folder", "file.jpg"); + expect(statSync(testFilePath).size).toEqual(1 * 1024 * 1024); }); it("Supports multiple input folders", () => { @@ -98,15 +95,15 @@ describe("Assets PostProcess", () => { const expectedFiles = ["folder/file_a.jpg", "folder/file_b.jpg", "folder/file_c.jpg"]; expect(Object.keys(contents)).toEqual(expectedFiles); // test file_b overidden from source_b - const overiddenFilePath = path.resolve(mockDirs.appAssets, "folder", "file_b.jpg"); - expect(fs.statSync(overiddenFilePath).size).toEqual(1 * 1024 * overrideFileSize); + const overiddenFilePath = resolve(mockDirs.appAssets, "folder", "file_b.jpg"); + expect(statSync(overiddenFilePath).size).toEqual(1 * 1024 * overrideFileSize); }); it("populates contents json", () => { mockLocalAssets({ "test.jpg": mockFile }); runAssetsPostProcessor(); const contents = readAppAssetContents(); - expect("test.jpg" in contents).toBeTrue(); + expect("test.jpg" in contents).toEqual(true); }); it("Populates global assets from named or root folder", () => { @@ -122,7 +119,6 @@ describe("Assets PostProcess", () => { "test1.jpg": { ...mockFileEntry, filePath: "global/test1.jpg" }, "test3.jpg": mockFileEntry, }); - mockFs.restore(); }); it("Populates assets with no overrides", () => { @@ -157,7 +153,7 @@ describe("Assets PostProcess", () => { theme_test: { global: { ...mockFileEntry, filePath: "theme_test/test.jpg" } }, }); }); - it("Populates combined theme and language overrides in any folder order ", () => { + it("Populates combined theme and language overrides in any folder order", () => { mockLocalAssets({ "test1.jpg": mockFile, "test2.jpg": mockFile, @@ -199,7 +195,7 @@ describe("Assets PostProcess", () => { theme_ignored: { "test.jpg": mockFile }, }); runAssetsPostProcessor({ app_themes_available: ["testTheme"] }); - expect(readdirSync(mockDirs.appAssets)).toEqual([ + expect(readdirSync(mockDirs.appAssets).sort()).toEqual([ "contents.json", "test.jpg", "theme_testTheme", @@ -234,7 +230,7 @@ describe("Assets PostProcess", () => { ke_sw: { "test.jpg": mockFile }, }); runAssetsPostProcessor({ filter_language_codes: ["tz_sw"] }); - expect(readdirSync(mockDirs.appAssets)).toEqual(["contents.json", "test.jpg", "tz_sw"]); + expect(readdirSync(mockDirs.appAssets).sort()).toEqual(["contents.json", "test.jpg", "tz_sw"]); }); it("supports nested lang and theme folders", () => { @@ -274,7 +270,7 @@ describe("Assets PostProcess", () => { /** QA tests */ it("throws error on duplicate overrides", () => { - const errorLogger = useMockErrorLogger(); + const mockLogger = useMockLogger(); mockLocalAssets({ "test.jpg": mockFile, theme_test: { @@ -288,7 +284,8 @@ describe("Assets PostProcess", () => { filter_language_codes: ["tz_sw"], app_themes_available: ["test"], }); - expect(errorLogger).toHaveBeenCalledOnceWith({ + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith({ msg1: "Duplicate overrides detected", msg2: "test.jpg [theme_test] [tz_sw]", logOnly: true, @@ -311,8 +308,8 @@ describe("Assets PostProcess", () => { it("warns on untracked assets", () => { const { localAssets } = mockDirs; - const untrackedPath = path.resolve(localAssets, "tz_sw", "untracked.jpg"); - fs.writeFileSync(untrackedPath, mockFile); + const untrackedPath = resolve(localAssets, "tz_sw", "untracked.jpg"); + writeFileSync(untrackedPath, mockFile); runAssetsPostProcessor(); expect(mockWarningLogger).toHaveBeenCalledWith({ msg1: "Translated assets found without corresponding global", @@ -358,5 +355,5 @@ function stubDeploymentConfig(stub: IDeploymentConfigStub = {}) { APP_THEMES: { available: app_themes_available }, } as any, }; - spyOn(ActiveDeployment, "get").and.returnValue(stubDeployment as IDeploymentConfigJson); + jest.spyOn(ActiveDeployment, "get").mockReturnValue(stubDeployment as IDeploymentConfigJson); } diff --git a/packages/scripts/src/commands/app-data/postProcess/assets.ts b/packages/scripts/src/commands/app-data/postProcess/assets.ts index ba6528d90a..9b8dff42f8 100644 --- a/packages/scripts/src/commands/app-data/postProcess/assets.ts +++ b/packages/scripts/src/commands/app-data/postProcess/assets.ts @@ -170,7 +170,8 @@ export class AssetsPostProcessor { const filtered: typeof sourceAssets = {}; const { assets_filter_function } = this.activeDeployment.app_data; const { filter_language_codes } = this.activeDeployment.translations; - const filter_theme_names = this.activeDeployment.app_config.APP_THEMES.available; + // themes are defined in runtime app config which may not be available during scripts + const filter_theme_names = this.activeDeployment.app_config.APP_THEMES?.available || []; // remove contents file from gdrive download delete sourceAssets["_contents.json"]; diff --git a/packages/scripts/src/commands/app-data/postProcess/sheets.spec.ts b/packages/scripts/src/commands/app-data/postProcess/sheets.spec.ts index b298841057..625658983c 100644 --- a/packages/scripts/src/commands/app-data/postProcess/sheets.spec.ts +++ b/packages/scripts/src/commands/app-data/postProcess/sheets.spec.ts @@ -1,9 +1,6 @@ import { AssetsPostProcessor } from "./assets"; import type { IDeploymentConfigJson } from "../../deployment/common"; -import fs from "fs-extra"; -import mockFs from "mock-fs"; - // Use default imports to allow spying on functions and replacing with mock methods import { ActiveDeployment } from "../../deployment/get"; import path from "path"; @@ -17,3 +14,7 @@ const mockDirs = { }; // TODO +/** yarn workspace scripts test -t sheets.spec.ts */ +describe("PostProcess Sheets", () => { + it.skip("TODO", () => expect(true).toEqual(true)); +}); diff --git a/packages/scripts/src/commands/deployment/common.ts b/packages/scripts/src/commands/deployment/common.ts index efc584fbec..10e8050cfa 100644 --- a/packages/scripts/src/commands/deployment/common.ts +++ b/packages/scripts/src/commands/deployment/common.ts @@ -3,20 +3,28 @@ import { logWarning } from "shared"; import { readJSONSync } from "fs-extra"; import path from "path"; -import { DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS, getDefaultAppConfig } from "data-models"; -import type { IDeploymentConfig, IDeploymentConfigJson } from "data-models"; +import { DEPLOYMENT_CONFIG_DEFAULTS, getDefaultAppConfig } from "data-models"; +import type { + IDeploymentConfig, + IDeploymentConfigGenerated, + IDeploymentConfigJson, +} from "data-models"; import { DEPLOYMENTS_PATH } from "../../paths"; import { getStackFileNames, loadDeploymentJson } from "./utils"; +import { toEmptyObject } from "shared/src/utils/object-utils"; // re-export of type for convenience export type { IDeploymentConfigJson }; /** Create a new deployment config with default values */ -export function generateDeploymentConfig(name: string): IDeploymentConfig { - const config = DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS; +export function generateDeploymentConfig(name: string) { + // populate placeholder properties for all nested appConfig to make it easier to + // apply overrides to single nested properties + const app_config = toEmptyObject(getDefaultAppConfig()); + // combine with deployment config defaults + const config: IDeploymentConfigGenerated = { ...DEPLOYMENT_CONFIG_DEFAULTS, app_config }; config.name = name; - config.app_config = getDefaultAppConfig(); return config; } diff --git a/packages/scripts/src/commands/deployment/compile.ts b/packages/scripts/src/commands/deployment/compile.ts index 8605778e94..c59dea1922 100644 --- a/packages/scripts/src/commands/deployment/compile.ts +++ b/packages/scripts/src/commands/deployment/compile.ts @@ -8,6 +8,7 @@ import { ROOT_DIR } from "../../paths"; import { Logger } from "../../utils"; import { IDeploymentConfigJson } from "./common"; import { convertFunctionsToStrings } from "./utils"; +import { cleanEmptyObject } from "shared/src/utils/object-utils"; const program = new Command("compile"); interface IOptions { @@ -82,6 +83,9 @@ function convertDeploymentTsToJson( const converted = convertFunctionsToStrings(rewritten); + // remove empty placeholders populated by override config + converted.app_config = cleanEmptyObject(converted.app_config); + return { ...converted, _workspace_path, _config_ts_path, _config_version }; } diff --git a/packages/scripts/src/tasks/providers/appData.ts b/packages/scripts/src/tasks/providers/appData.ts index dc2dca96f2..a51d3da942 100644 --- a/packages/scripts/src/tasks/providers/appData.ts +++ b/packages/scripts/src/tasks/providers/appData.ts @@ -1,9 +1,11 @@ import { writeFileSync } from "fs-extra"; import path from "path"; +import packageJSON from "../../../../../package.json"; import { parseCommand } from "../../commands"; import { WorkflowRunner } from "../../commands/workflow/run"; import { SRC_ASSETS_PATH } from "../../paths"; import { IContentsEntry, replicateDir } from "../../utils"; +import { IDeploymentConfigJson, IDeploymentRuntimeConfig } from "data-models"; /** Prepare sourcely cached assets for population to app */ const postProcessAssets = async (options: { sourceAssetsFolders: string[] }) => { @@ -30,24 +32,49 @@ const postProcessSheets = async (options: { */ const copyDeploymentDataToApp = () => { const { app_data } = WorkflowRunner.config; + + // copy filtered subset of app_data const copiedFolders = ["assets", "sheets", "translations"]; - // omit index files const filter_fn = (entry: IContentsEntry) => { const [baseDir] = entry.relativePath.split("/"); return copiedFolders.includes(baseDir); }; - const source = app_data.output_path; - const target = path.resolve(SRC_ASSETS_PATH, "app_data"); - replicateDir(source, target, { filter_fn }); + // copy folders + const sourceFolder = app_data.output_path; + const targetFolder = path.resolve(SRC_ASSETS_PATH, "app_data"); + replicateDir(sourceFolder, targetFolder, { filter_fn }); // HACK - Angular webpack won't always live-reload when changes only made to asset files // so write an arbitrary variable that can be imported into the app and will trigger reload // https://github.com/angular/angular-cli/issues/22751 // https://github.com/webpack/webpack-dev-server/issues/3794 writeFileSync( - path.resolve(target, "index.ts"), + path.resolve(targetFolder, "index.ts"), `export const DEV_COMPILE_TIME = ${new Date().getTime()}` ); + + // write runtime deployment config + const configTarget = path.resolve(targetFolder, "deployment.json"); + const runtimeConfig = generateRuntimeConfig(WorkflowRunner.config); + writeFileSync(configTarget, JSON.stringify(runtimeConfig, null, 2)); }; +function generateRuntimeConfig(deploymentConfig: IDeploymentConfigJson): IDeploymentRuntimeConfig { + const { analytics, api, app_config, error_logging, firebase, git, name, supabase, web } = + deploymentConfig; + + return { + _app_builder_version: packageJSON.version, + _content_version: git.content_tag_latest || "", + analytics, + api, + app_config, + error_logging, + firebase, + name, + supabase, + web, + }; +} + export default { postProcessAssets, postProcessSheets, copyDeploymentDataToApp }; diff --git a/packages/scripts/test/helpers/reporters.ts b/packages/scripts/test/helpers/reporters.ts deleted file mode 100644 index 6420c40ad0..0000000000 --- a/packages/scripts/test/helpers/reporters.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { SpecReporter } from "jasmine-spec-reporter"; - -jasmine.getEnv().clearReporters(); // remove default reporter logs -jasmine.getEnv().addReporter( - new SpecReporter({ - // add jasmine-spec-reporter - spec: { - displayPending: true, - }, - }) -); diff --git a/packages/scripts/test/helpers/utils.ts b/packages/scripts/test/helpers/utils.ts index 58d9a2f718..0d131d7763 100644 --- a/packages/scripts/test/helpers/utils.ts +++ b/packages/scripts/test/helpers/utils.ts @@ -1,19 +1,31 @@ +import { resolve } from "path"; +import { SCRIPTS_WORKSPACE_PATH } from "shared"; import { Logger } from "shared/src/utils/logging/console-logger"; /************************************************************* - * Test utilties + * Test utilities *************************************************************/ -/** Mock function that will replace default `Logger` function to instead just record any invocations */ -export function useMockErrorLogger() { - const mockErrorLogger = jasmine.createSpy("mockErrorLogger", Logger.error); - spyOn(Logger, "error").and.callFake(mockErrorLogger); - return mockErrorLogger; +/** + * Create spy to track usage of console Logger method + * @param callOriginal specify whether to still call original Logger + * methods after spy intercept + */ +export function useMockLogger(callOriginal = true) { + const error = jest.spyOn(Logger, "error"); + const warning = jest.spyOn(Logger, "warning"); + if (!callOriginal) { + error.mockImplementation(jest.fn()); + warning.mockImplementation(jest.fn()); + } + return { error, warning }; } -/** Mock function that will replace default `Logger` function to instead just record any invocations */ -export function useMockWarningLogger() { - const mockWarningLogger = jasmine.createSpy("mockWarningLogger", Logger.warning); - spyOn(Logger, "warning").and.callFake(mockWarningLogger); - return mockWarningLogger; -} +const TEST_DATA_DIR = resolve(SCRIPTS_WORKSPACE_PATH, "test", "data"); +/** Common paths used for test data */ +export const TEST_DATA_PATHS = { + SHEETS_CACHE_FOLDER: resolve(TEST_DATA_DIR, "cache"), + SHEETS_INPUT_FOLDER: resolve(TEST_DATA_DIR, "input"), + SHEETS_OUTPUT_FOLDER: resolve(TEST_DATA_DIR, "output"), + TEST_DATA_DIR, +}; diff --git a/packages/scripts/test/setup.ts b/packages/scripts/test/setup.ts new file mode 100644 index 0000000000..3594f74ed3 --- /dev/null +++ b/packages/scripts/test/setup.ts @@ -0,0 +1,15 @@ +import { getGlobalFileLogger } from "shared/src/utils/logging/file-logger"; +import { TEST_DATA_PATHS } from "./helpers/utils"; +import { ensureDirSync, emptyDirSync } from "fs-extra"; + +export default () => { + // Create a logger instance so that parallel test calls don't try to create/empty + // directory when initiating for first time + getGlobalFileLogger(); + + // Ensure test data folders are cleaned + ensureDirSync(TEST_DATA_PATHS.SHEETS_CACHE_FOLDER); + ensureDirSync(TEST_DATA_PATHS.SHEETS_OUTPUT_FOLDER); + emptyDirSync(TEST_DATA_PATHS.SHEETS_CACHE_FOLDER); + emptyDirSync(TEST_DATA_PATHS.SHEETS_CACHE_FOLDER); +}; diff --git a/packages/scripts/tsconfig.json b/packages/scripts/tsconfig.json index 07a90aef31..40d3fa25f6 100644 --- a/packages/scripts/tsconfig.json +++ b/packages/scripts/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": true, "moduleResolution": "node", "module": "CommonJS", + "resolveJsonModule": true, "esModuleInterop": true, // Global type definitions. "typeRoots": ["./node_modules/@types"], diff --git a/packages/scripts/types/index.ts b/packages/scripts/types/index.ts index ea4003f0c5..0d8c009d18 100644 --- a/packages/scripts/types/index.ts +++ b/packages/scripts/types/index.ts @@ -1,2 +1,2 @@ // Provide access to FlowTypes used in the app -export { FlowTypes, IDeploymentConfig, DEPLOYMENT_CONFIG_EXAMPLE_DEFAULTS } from "data-models"; +export { FlowTypes, IDeploymentConfig } from "data-models"; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9ccbcebf45..f9066471eb 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -2,3 +2,10 @@ export interface ITemplatedStringVariable { value?: string; variables?: { [key: string]: ITemplatedStringVariable }; } + +/** A recursive version of Partial, making all properties, included nested ones, optional. + * Copied from https://stackoverflow.com/a/47914631 + */ +export type RecursivePartial = { + [P in keyof T]?: RecursivePartial; +}; diff --git a/packages/shared/src/utils/file-utils.spec.ts b/packages/shared/src/utils/file-utils.spec.ts index 8d82b62d18..d11ac8fc07 100644 --- a/packages/shared/src/utils/file-utils.spec.ts +++ b/packages/shared/src/utils/file-utils.spec.ts @@ -1,4 +1,4 @@ -import { setNestedProperty, sortJsonKeys } from "./file-utils"; +import { setNestedProperty } from "./file-utils"; describe("setNestedProperty", () => { it("Sets object deep property", () => { @@ -16,21 +16,3 @@ describe("setNestedProperty", () => { expect(res).toEqual({ a: { b: { c: 1 } } }); }); }); - -describe("sortJsonKeys", () => { - it("Sorts nested json by key", () => { - const input = { - b: "foo", - c: null, - a: { - f: 6, - e: 5, - }, - d: [], - }; - const res = sortJsonKeys(input); - expect(Object.keys(res)).toEqual(["a", "b", "c", "d"]); - expect(Object.keys(res.a)).toEqual(["e", "f"]); - expect(Object.values(res.a)).toEqual([5, 6]); - }); -}); diff --git a/packages/shared/src/utils/file-utils.ts b/packages/shared/src/utils/file-utils.ts index fb2fafe00d..718333baf1 100644 --- a/packages/shared/src/utils/file-utils.ts +++ b/packages/shared/src/utils/file-utils.ts @@ -2,7 +2,7 @@ import * as fs from "fs-extra"; import * as path from "path"; import * as os from "os"; import { createHash, randomUUID } from "crypto"; -import { logWarning } from "./logging.utils"; +import { Logger, logWarning } from "./logging.utils"; import { tmpdir } from "os"; /** @@ -149,12 +149,7 @@ export function generateFolderFlatMap( const relativePath = path.relative(folderPath, filePath).split(path.sep).join("/"); const shouldInclude = options.filterFn ? options.filterFn(relativePath) : true; if (shouldInclude) { - // generate size and md5 checksum stats - const { size, mtime } = fs.statSync(filePath); - const modifiedTime = mtime.toISOString(); - // write size in kb to 1 dpclear - const size_kb = Math.round(size / 102.4) / 10; - const md5Checksum = getFileMD5Checksum(filePath); + const { md5Checksum, modifiedTime, size_kb } = getFileStats(filePath); const entry: IContentsEntry = { relativePath, size_kb, md5Checksum, modifiedTime }; if (options.includeLocalPath) { entry.localPath = filePath; @@ -165,6 +160,16 @@ export function generateFolderFlatMap( return flatMap; } +function getFileStats(filePath: string) { + // generate size and md5 checksum stats + const { size, mtime } = fs.statSync(filePath); + const modifiedTime = mtime.toISOString(); + // write size in kb to 1 dpclear + const size_kb = Math.round(size / 102.4) / 10; + const md5Checksum = getFileMD5Checksum(filePath); + return { size_kb, md5Checksum, modifiedTime }; +} + export interface IContentsEntry { relativePath: string; size_kb: number; @@ -480,6 +485,24 @@ export function replicateDir( return ops; } +/** Copy a file from src to target, only replacing if unchanged */ +export function replicateFile(src: string, target: string) { + const srcExists = fs.pathExistsSync(src); + if (!srcExists) return Logger.error({ msg1: "File not found", msg2: src }); + const srcStats = getFileStats(src); + // skip if target file same contents as src + if (fs.pathExistsSync(target)) { + const targetStats = getFileStats(target); + if (srcStats.md5Checksum === targetStats.md5Checksum) { + return; + } + } + fs.ensureDirSync(path.dirname(target)); + fs.copyFileSync(src, target); + const mtime = new Date(srcStats.modifiedTime); + fs.utimesSync(target, mtime, mtime); +} + /** * Copy all files from src to target folder, overriding target files with src * and keeping original modified times @@ -555,18 +578,3 @@ export const cleanupEmptyFolders = (folder: string) => { fs.rmdirSync(folder); } }; - -/** Order a nested json-like object in alphabetical key order */ -export const sortJsonKeys = >(json: T): T => { - // return non json-type data as-is - if (!json || {}.constructor !== json.constructor) { - return json; - } - // recursively sort any nested json by key - return Object.keys(json) - .sort() - .reduce((obj, key) => { - obj[key] = sortJsonKeys(json[key]); - return obj; - }, {}) as T; -}; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 4f4eda8c93..3a9542a8f9 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -3,5 +3,6 @@ export * from "./cli-utils"; export * from "./delimiters"; export * from "./file-utils"; export * from "./logging.utils"; +export * from "./object-utils"; export * from "./string-utils"; export * from "./typescript-utils"; diff --git a/packages/shared/src/utils/logging/file-logger.ts b/packages/shared/src/utils/logging/file-logger.ts index 06987d5a0e..f1c954a2ec 100644 --- a/packages/shared/src/utils/logging/file-logger.ts +++ b/packages/shared/src/utils/logging/file-logger.ts @@ -1,35 +1,20 @@ import winston from "winston"; import path from "path"; -import { emptyDirSync, ensureDirSync, truncateSync } from "fs-extra"; +import { truncateSync } from "fs-extra"; import { _wait } from "../async-utils"; -import { Writable } from "stream"; import { existsSync } from "fs"; import { SCRIPTS_LOGS_DIR } from "../../paths"; +import { getGlobalMemoryLoggerTransport } from "./memory-logger"; -const logLevels = ["debug", "info", "warning", "error"] as const; +const logLevels = ["debug", "info", "warn", "error"] as const; type ILogLevel = (typeof logLevels)[number]; -interface ILogEntry { - level: ILogLevel; - message?: string; - details?: any; - source?: string; -} - -// Declare a history variable that can be written to via a stream -let logHistory = ""; /** Retrieve all logs from current session for a given variable */ -export function getLogs(level: ILogLevel, message?: string) { - const logEntries: ILogEntry[] = logHistory - .split("\n") - .filter((v) => v) - .map((v) => JSON.parse(v)); - const logLevelEntries = logEntries.filter((entry) => entry.level === level); - if (message) { - return logLevelEntries.filter((entry) => entry.message === message); - } - return logLevelEntries; +export function getLogs(level: ILogLevel) { + const logger = getGlobalMemoryLoggerTransport(); + return logger.get(level); } + export function getLogFiles() { const logFiles: { [level in ILogLevel]: string } = {} as any; for (const level of logLevels) { @@ -38,40 +23,52 @@ export function getLogFiles() { return logFiles; } -export function clearLogs(attempt = 0) { - logHistory = ""; - try { - const logFiles = getLogFiles(); - for (const logFile of Object.values(logFiles)) { - if (existsSync(logFile)) { - truncateSync(logFile); +/** + * Clear existing log data. Clears all in-memory logs and optional persisted file + * @param includeFiles - clear log files written to disk. + * Operation will fail if open during parallel operations + */ +export function clearLogs(includeFiles = false, attempt = 0) { + // Clear in-memory logs + const memoryLogger = getGlobalMemoryLoggerTransport(); + memoryLogger.clear(); + // Clear file-based logs + if (includeFiles) { + try { + const logFiles = getLogFiles(); + for (const logFile of Object.values(logFiles)) { + if (existsSync(logFile)) { + truncateSync(logFile); + } } + } catch (error) { + attempt++; + if (attempt > 5) { + throw error; + } + console.log("could not clear logs, retrying...", attempt); + return clearLogs(includeFiles, attempt); } - } catch (error) { - attempt++; - if (attempt > 5) { - throw error; - } - console.log("could not clear logs, retrying...", attempt); - return clearLogs(attempt); } } /** - * Create loggers that write to file based on level and also save all logs to a single string + * Create loggers that write to file based on level and also save to memory * for easy querying */ -function getGlobalFileLogger() { +export function getGlobalFileLogger() { const g = global as any; if (g.logger) { return g.logger as winston.Logger; } - // setup files - logHistory = ""; - ensureDirSync(SCRIPTS_LOGS_DIR); - emptyDirSync(SCRIPTS_LOGS_DIR); + // remove any previous log files + clearLogs(true); + + // create in-memory logger transport + const memoryLogger = getGlobalMemoryLoggerTransport(); + + // create file-write logger transports const logFiles = getLogFiles(); - // file transports const fileTransports = logLevels.map( (level) => new winston.transports.File({ @@ -80,25 +77,18 @@ function getGlobalFileLogger() { format: winston.format.prettyPrint(), }) ); + + // create unified logger const logger = winston.createLogger({ level: "info", - transports: fileTransports, - }); - // stream (memory) transport - const logStream = new Writable(); - logStream._write = (chunk, encoding, next) => { - logHistory = logHistory += chunk.toString(); - next(); - }; - const streamTransport = new winston.transports.Stream({ - stream: logStream, - format: winston.format.json(), + transports: [memoryLogger, ...fileTransports], }); - logger.add(streamTransport); + g.logger = logger; return logger; } +/** Create a child instance of the file logger with additional context meta */ export function createChildFileLogger(meta = {}) { const logger = getGlobalFileLogger(); return logger.child(meta); diff --git a/packages/shared/src/utils/logging/memory-logger.ts b/packages/shared/src/utils/logging/memory-logger.ts new file mode 100644 index 0000000000..7f6fb16a5a --- /dev/null +++ b/packages/shared/src/utils/logging/memory-logger.ts @@ -0,0 +1,47 @@ +import Transport from "winston-transport"; + +const logLevels = ["debug", "info", "warn", "error"] as const; +type ILogLevel = (typeof logLevels)[number]; + +interface ILogEntry { + level: ILogLevel; + message?: string; + details?: any; + source?: string; +} + +/** + * In-memory log transport capable of returning all stored logs + * Should be added as a transport to the default logging instance + **/ +export class MemoryLogger extends Transport { + private cache: { [level in ILogLevel]: ILogEntry[] }; + + constructor() { + super(); + this.clear(); + } + public get(level: ILogLevel) { + return this.cache[level]; + } + clear() { + this.cache = { + debug: [], + error: [], + info: [], + warn: [], + }; + } + log(entry: ILogEntry, callback) { + const { level, details, message, source } = entry; + this.cache[level].push({ level, details, message, source }); + callback(); + } +} + +/** Provide access to a shared global instance of memory logger */ +export function getGlobalMemoryLoggerTransport() { + const g = global as any; + g["logCache"] ??= new MemoryLogger(); + return g["logCache"] as MemoryLogger; +} diff --git a/packages/shared/src/utils/object-utils.spec.ts b/packages/shared/src/utils/object-utils.spec.ts new file mode 100644 index 0000000000..6489335529 --- /dev/null +++ b/packages/shared/src/utils/object-utils.spec.ts @@ -0,0 +1,74 @@ +import { + cleanEmptyObject, + isEmptyObjectDeep, + isObjectLiteral, + sortJsonKeys, + toEmptyObject, +} from "./object-utils"; + +const MOCK_NESTED_OBJECT = { + obj_1: { + obj_1_1: { + number: 2, + obj_1_1_1: {}, + }, + obj_1_2: { + obj_1_2_1: {}, + }, + }, + string: "hi", + number: 1, +}; + +describe("Object Utils", () => { + it("isObjectLiteral", () => { + expect(isObjectLiteral({})).toEqual(true); + expect(isObjectLiteral({ string: "hello" })).toEqual(true); + expect(isObjectLiteral(undefined)).toEqual(false); + expect(isObjectLiteral([])).toEqual(false); + expect(isObjectLiteral(new Date())).toEqual(false); + }); + + it("isEmptyObjectDeep", () => { + expect(isEmptyObjectDeep({})).toEqual(true); + expect(isEmptyObjectDeep(undefined)).toEqual(false); + expect(isEmptyObjectDeep({ key: { key: { key: {} } } })).toEqual(true); + expect(isEmptyObjectDeep({ key: { key: { key: undefined } } })).toEqual(false); + }); + + it("toEmptyObject", () => { + const res = toEmptyObject(MOCK_NESTED_OBJECT); + expect(res).toEqual({ + obj_1: { obj_1_1: { obj_1_1_1: {} }, obj_1_2: { obj_1_2_1: {} } }, + } as any); + }); + + it("cleanEmptyObject", () => { + const res = cleanEmptyObject(MOCK_NESTED_OBJECT); + expect(res).toEqual({ + obj_1: { + obj_1_1: { + number: 2, + }, + }, + string: "hi", + number: 1, + }); + }); + + it("sortJsonKeys", () => { + const input = { + b: "foo", + c: null, + a: { + f: 6, + e: 5, + }, + d: [], + }; + const res = sortJsonKeys(input); + expect(Object.keys(res)).toEqual(["a", "b", "c", "d"]); + expect(Object.keys(res.a)).toEqual(["e", "f"]); + expect(Object.values(res.a)).toEqual([5, 6]); + }); +}); diff --git a/packages/shared/src/utils/object-utils.ts b/packages/shared/src/utils/object-utils.ts new file mode 100644 index 0000000000..09f5746238 --- /dev/null +++ b/packages/shared/src/utils/object-utils.ts @@ -0,0 +1,79 @@ +/** + * Determine whether a value is a literal object type (`{}`) + * Adapted from discussion https://stackoverflow.com/q/1173549 + */ +export function isObjectLiteral(v: any) { + return v ? v.constructor === {}.constructor : false; +} + +/** Check if an object is either empty or contains only empty child */ +export function isEmptyObjectDeep(v: any) { + return isObjectLiteral(v) && Object.values(v).every((x) => isEmptyObjectDeep(x)); +} + +/** + * Takes a json object and empties all data inside, just leaving nested entry nodes + * This is used to create placeholder objects for deeply nested partial configurations + * @example + * ```ts + * const obj = {parent:{text:'hello',obj:{number:1}}} + * toEmptyObject(obj) + * // output + * {parent:{obj:{}}} + * ``` + ***/ +export function toEmptyObject>(obj: T) { + const emptied = {} as any; + if (isObjectLiteral(obj)) { + for (const [key, value] of Object.entries(obj)) { + if (isObjectLiteral(value)) { + emptied[key] = toEmptyObject(value); + } + } + } else { + console.error("[toEmptyObject] invalid input: " + obj); + return obj; + } + return emptied as T; +} + +/** + * Takes an input object with deeply nested keys and removes all child entries + * that are either empty `{}` or contain only empty child entries `{nested:{}}` + * @example + * ```ts + * + * ``` + */ +export function cleanEmptyObject(obj: Record) { + const cleaned = {} as any; + if (obj.constructor === {}.constructor) { + for (const [key, value] of Object.entries(obj)) { + if (value.constructor === {}.constructor) { + if (!isEmptyObjectDeep(value)) { + cleaned[key] = cleanEmptyObject(value); + } + } else { + cleaned[key] = value; + } + } + } else { + return cleaned; + } + return cleaned; +} + +/** Order a nested json object literal in alphabetical key order */ +export const sortJsonKeys = >(json: T): T => { + // return non json-type data as-is + if (!isObjectLiteral(json)) { + return json; + } + // recursively sort any nested json by key + return Object.keys(json) + .sort() + .reduce((obj, key) => { + obj[key] = sortJsonKeys(json[key]); + return obj; + }, {}) as T; +}; diff --git a/packages/test-visual/package.json b/packages/test-visual/package.json index 07ac830808..f19807ff71 100644 --- a/packages/test-visual/package.json +++ b/packages/test-visual/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "archiver": "^5.3.0", - "axios": "^1.6.0", + "axios": "^1.7.4", "boxen": "^5.1.2", "chalk": "^4.1.2", "commander": "^8.2.0", diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index cd0fdc76fb..d754629e8e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,20 +1,13 @@ import { NgModule } from "@angular/core"; -import { PreloadAllModules, Route, RouterModule, Routes } from "@angular/router"; -import { APP_CONFIG } from "./data"; +import { PreloadAllModules, RouterModule, Routes } from "@angular/router"; import { TourComponent } from "./feature/tour/tour.component"; -// TODO: These should come from the appConfigService -const { APP_ROUTE_DEFAULTS } = APP_CONFIG; - -/** Routes specified from data-models */ -const DataRoutes: Routes = [ - { path: "", redirectTo: APP_ROUTE_DEFAULTS.home_route, pathMatch: "full" }, - ...APP_ROUTE_DEFAULTS.redirects, -]; -const fallbackRoute: Route = { path: "**", redirectTo: APP_ROUTE_DEFAULTS.fallback_route }; - -/** Routes required for main app features */ -const FeatureRoutes: Routes = [ +/** + * Routes required for main app features + * Additional home template redirects and fallback routes will be specified + * from deployment config via the AppConfigService + **/ +export const APP_FEATURE_ROUTES: Routes = [ { path: "campaigns", loadChildren: () => import("./feature/campaign/campaign.module").then((m) => m.CampaignModule), @@ -68,7 +61,7 @@ const FeatureRoutes: Routes = [ @NgModule({ imports: [ - RouterModule.forRoot([...FeatureRoutes, ...DataRoutes, fallbackRoute], { + RouterModule.forRoot(APP_FEATURE_ROUTES, { preloadingStrategy: PreloadAllModules, useHash: false, anchorScrolling: "enabled", diff --git a/src/app/app.component.html b/src/app/app.component.html index f6931026ed..27bacab8d8 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -16,16 +16,19 @@ {{ sideMenuDefaults.title }}
- - - {{ CONTENT_VERSION }} - - - {{ APP_VERSION }} - + @if (sideMenuDefaults.should_show_version) { + @if (deploymentConfig._content_version; as CONTENT_VERSION) { + + + {{ CONTENT_VERSION }} + + + } @else { + {{ deploymentConfig._app_builder_version }} + } + } ({{ DEPLOYMENT_NAME }})({{ deploymentConfig.name }})
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index bcbf94702c..b16b1adf05 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -41,6 +41,7 @@ import { SeoService } from "./shared/services/seo/seo.service"; import { FeedbackService } from "./feature/feedback/feedback.service"; import { ShareService } from "./shared/services/share/share.service"; import { LocalStorageService } from "./shared/services/local-storage/local-storage.service"; +import { DeploymentService } from "./shared/services/deployment/deployment.service"; @Component({ selector: "app-root", @@ -48,15 +49,17 @@ import { LocalStorageService } from "./shared/services/local-storage/local-stora styleUrls: ["app.component.scss"], }) export class AppComponent { - APP_VERSION = environment.version; - CONTENT_VERSION = environment.deploymentConfig.git.content_tag_latest; - DEPLOYMENT_NAME = environment.deploymentName; appConfig: IAppConfig; appAuthenticationDefaults: IAppConfig["APP_AUTHENTICATION_DEFAULTS"]; sideMenuDefaults: IAppConfig["APP_SIDEMENU_DEFAULTS"]; footerDefaults: IAppConfig["APP_FOOTER_DEFAULTS"]; /** Track when app ready to render sidebar and route templates */ public renderAppTemplates = false; + + public get deploymentConfig() { + return this.deploymentService.config; + } + /** * A space-separated list of values, hierarchically representing the current platform, * e.g. on iPhone the value would be "mobile ios iphone". @@ -66,6 +69,9 @@ export class AppComponent { platforms: string; constructor( + // Component UI + private deploymentService: DeploymentService, + // 3rd Party Services private platform: Platform, private cdr: ChangeDetectorRef, @@ -148,9 +154,10 @@ export class AppComponent { } /** Populate contact fields that may be used by other services during initialisation */ private async populateAppInitFields() { - this.localStorageService.setProtected("DEPLOYMENT_NAME", this.DEPLOYMENT_NAME); - this.localStorageService.setProtected("APP_VERSION", this.APP_VERSION); - this.localStorageService.setProtected("CONTENT_VERSION", this.CONTENT_VERSION); + const { _content_version, _app_builder_version, name } = this.deploymentService.config; + this.localStorageService.setProtected("DEPLOYMENT_NAME", name); + this.localStorageService.setProtected("APP_VERSION", _app_builder_version); + this.localStorageService.setProtected("CONTENT_VERSION", _content_version); // HACK - ensure first_app_launch migrated from event service if (!this.localStorageService.getProtected("APP_FIRST_LAUNCH")) { await this.appEventService.ready(); @@ -164,7 +171,7 @@ export class AppComponent { * Currently only run on native where specified (but can comment out for testing locally) */ private async loadAuthConfig() { - const { firebase } = environment.deploymentConfig; + const { firebase } = this.deploymentService.config; const { enforceLogin } = this.appAuthenticationDefaults; const ensureLogin = firebase.config && enforceLogin && Capacitor.isNativePlatform(); if (ensureLogin) { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 02bfbf6edb..591b129364 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,13 +3,12 @@ import { BrowserModule } from "@angular/platform-browser"; import { FormsModule } from "@angular/forms"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { RouteReuseStrategy } from "@angular/router"; -import { HttpClientModule } from "@angular/common/http"; +import { HTTP_INTERCEPTORS, HttpClientModule } from "@angular/common/http"; import { IonicModule, IonicRouteStrategy } from "@ionic/angular"; // Libs import { LottieModule } from "ngx-lottie"; import player from "lottie-web"; -import { MatomoModule, MatomoRouterModule } from "ngx-matomo-client"; // Native import { HTTP } from "@ionic-native/http/ngx"; @@ -19,12 +18,12 @@ import { Device } from "@ionic-native/device/ngx"; import { AppComponent } from "./app.component"; import { AppRoutingModule } from "./app-routing.module"; import { SharedModule } from "./shared/shared.module"; -import { environment } from "src/environments/environment"; -import { httpInterceptorProviders } from "./shared/services/server/interceptors"; import { TemplateComponentsModule } from "./shared/components/template/template.module"; import { ContextMenuModule } from "./shared/modules/context-menu/context-menu.module"; import { TourModule } from "./feature/tour/tour.module"; import { ErrorHandlerService } from "./shared/services/error-handler/error-handler.service"; +import { ServerAPIInterceptor } from "./shared/services/server/interceptors"; +import { DeploymentFeaturesModule } from "./deployment-features.module"; // Note we need a separate function as it's required // by the AOT compiler. @@ -47,18 +46,16 @@ export function lottiePlayerFactory() { // LottieCacheModule.forRoot(), TemplateComponentsModule, TourModule, - MatomoModule.forRoot({ - siteId: environment.analytics.siteId, - trackerUrl: environment.analytics.endpoint, - }), - MatomoRouterModule, ContextMenuModule, + DeploymentFeaturesModule, ], providers: [ { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, HTTP, Device, - httpInterceptorProviders, + // Use custom api interceptor to handle interaction with server backend + { provide: HTTP_INTERCEPTORS, useClass: ServerAPIInterceptor, multi: true }, + // Use custom error handler { provide: ErrorHandler, useClass: ErrorHandlerService }, ], bootstrap: [AppComponent], diff --git a/src/app/data/constants.ts b/src/app/data/constants.ts deleted file mode 100644 index b3401ca6b2..0000000000 --- a/src/app/data/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { getDefaultAppConfig, IAppConfig } from "data-models"; -import { environment } from "src/environments/environment"; -import { deepMergeObjects } from "../shared/utils"; - -const app_config_overrides = (environment.deploymentConfig as any).app_config || {}; - -/** List of constants provided by data-models combined with deployment-specific overrides */ -export const APP_CONFIG: IAppConfig = deepMergeObjects(getDefaultAppConfig(), app_config_overrides); diff --git a/src/app/data/index.ts b/src/app/data/index.ts index 9c2355a44d..a220084180 100644 --- a/src/app/data/index.ts +++ b/src/app/data/index.ts @@ -1,4 +1,3 @@ -export * from "./constants"; export * from "./app-data"; // Not used but forces angular to reload when asset jsons changed diff --git a/src/app/deployment-features.module.ts b/src/app/deployment-features.module.ts new file mode 100644 index 0000000000..463e4fcbc2 --- /dev/null +++ b/src/app/deployment-features.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from "@angular/core"; + +import { AnalyticsModule } from "./shared/services/analytics"; + +/** + * Module imports required for specific deployment features + * + * NOTE - as angular needs all modules to be statically defined during compilation + * it is not possible to conditionally load modules at runtime. + * + * Therefore all modules are defined and loaded as part of the core build process, + * but it is still possible to override this file to create specific feature-optimised builds + * + * This is a feature marked for future implementation + */ +@NgModule({ imports: [AnalyticsModule] }) +export class DeploymentFeaturesModule {} diff --git a/src/app/feature/feedback/feedback.service.ts b/src/app/feature/feedback/feedback.service.ts index afcb4b49b0..3058b28d29 100644 --- a/src/app/feature/feedback/feedback.service.ts +++ b/src/app/feature/feedback/feedback.service.ts @@ -16,7 +16,6 @@ import { import { UserMetaService } from "src/app/shared/services/userMeta/userMeta.service"; import { TemplateService } from "src/app/shared/components/template/services/template.service"; import { generateTimestamp } from "src/app/shared/utils"; -import { environment } from "src/environments/environment"; import { DbService } from "src/app/shared/services/db/db.service"; import { DBSyncService } from "src/app/shared/services/db/db-sync.service"; import { @@ -38,6 +37,7 @@ import { } from "src/app/shared/components/template/services/instance/template-action.registry"; import { SyncServiceBase } from "src/app/shared/services/syncService.base"; import { LocalStorageService } from "src/app/shared/services/local-storage/local-storage.service"; +import { DeploymentService } from "src/app/shared/services/deployment/deployment.service"; @Injectable({ providedIn: "root", @@ -80,7 +80,8 @@ export class FeedbackService extends SyncServiceBase { private router: Router, private themeService: ThemeService, private skinService: SkinService, - private templateActionRegistry: TemplateActionRegistry + private templateActionRegistry: TemplateActionRegistry, + private deploymentService: DeploymentService ) { super("Feedback"); this.subscribeToAppConfigChanges(); @@ -367,13 +368,14 @@ export class FeedbackService extends SyncServiceBase { * device info and user uuid */ public generateFeedbackMetadata() { + const { _app_builder_version, name } = this.deploymentService.config; const metadata: IFeedbackMetadata = { deviceInfo: this.deviceInfo, pathname: location.pathname, uuid: this.userMetaService.getUserMeta("uuid"), timestamp: generateTimestamp(), - app_version: environment.version, - app_deployment_name: environment.deploymentName, + app_version: _app_builder_version, + app_deployment_name: name, app_theme: this.themeService.getCurrentTheme(), app_skin: this.skinService.getActiveSkinName(), }; diff --git a/src/app/feature/theme/services/theme.service.spec.ts b/src/app/feature/theme/services/theme.service.spec.ts index 45052e902b..b1322e66d5 100644 --- a/src/app/feature/theme/services/theme.service.spec.ts +++ b/src/app/feature/theme/services/theme.service.spec.ts @@ -1,12 +1,42 @@ import { TestBed } from "@angular/core/testing"; import { ThemeService } from "./theme.service"; +import { LocalStorageService } from "src/app/shared/services/local-storage/local-storage.service"; +import { MockLocalStorageService } from "src/app/shared/services/local-storage/local-storage.service.spec"; +import { AppConfigService } from "src/app/shared/services/app-config/app-config.service"; +import { MockAppConfigService } from "src/app/shared/services/app-config/app-config.service.spec"; +import { IAppConfig } from "packages/data-models"; + +export class MockThemeService implements Partial { + ready() { + return true; + } + setTheme() {} + getCurrentTheme() { + return "mock_theme"; + } +} + +const MOCK_APP_CONFIG: Partial = { + APP_THEMES: { + available: ["MOCK_THEME_1", "MOCK_THEME_2"], + defaultThemeName: "MOCK_THEME_1", + }, +}; describe("ThemeService", () => { let service: ThemeService; beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + { provide: LocalStorageService, useValue: new MockLocalStorageService() }, + { + provide: AppConfigService, + useValue: new MockAppConfigService(MOCK_APP_CONFIG), + }, + ], + }); service = TestBed.inject(ThemeService); }); diff --git a/src/app/shared/components/header/header.component.ts b/src/app/shared/components/header/header.component.ts index 5beb297a83..755835bd17 100644 --- a/src/app/shared/components/header/header.component.ts +++ b/src/app/shared/components/header/header.component.ts @@ -3,8 +3,12 @@ import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NavigationEnd, NavigationStart, Router } from "@angular/router"; import { App } from "@capacitor/app"; import { Capacitor, PluginListenerHandle } from "@capacitor/core"; -import { Subscription, fromEvent, debounceTime, map } from "rxjs"; -import { IAppConfig, IHeaderColourOptions, IHeaderVariantOptions } from "../../model"; +import { Subscription, fromEvent, map } from "rxjs"; +import type { + IAppConfig, + IHeaderColourOptions, + IHeaderVariantOptions, +} from "data-models/appConfig"; import { AppConfigService } from "../../services/app-config/app-config.service"; import { IonHeader, ScrollBaseCustomEvent, ScrollDetail } from "@ionic/angular"; import { _wait } from "packages/shared/src/utils/async-utils"; @@ -70,14 +74,14 @@ export class headerComponent implements OnInit, OnDestroy { }); } // HACK - uncomment to test collapse - // this.appConfigService.updateAppConfig({ APP_HEADER_DEFAULTS: { collapse: true } }); + // this.appConfigService.setAppConfig({ APP_HEADER_DEFAULTS: { collapse: true } }); } subscribeToAppConfigChanges() { this.appConfigChanges$ = this.appConfigService.changesWithInitialValue$.subscribe( (changes: IAppConfig) => { if (changes.APP_HEADER_DEFAULTS) { - const headerConfig = this.appConfigService.APP_CONFIG.APP_HEADER_DEFAULTS; + const headerConfig = this.appConfigService.appConfig().APP_HEADER_DEFAULTS; this.headerConfig = headerConfig; this.updateHeaderConfig(); // handle collapse config changes diff --git a/src/app/shared/components/template/components/audio/audio.component.html b/src/app/shared/components/template/components/audio/audio.component.html index a692f46d7d..4d7fc3cd4e 100644 --- a/src/app/shared/components/template/components/audio/audio.component.html +++ b/src/app/shared/components/template/components/audio/audio.component.html @@ -19,7 +19,7 @@

{{ params.title }}

class="audio-range" max="100" aria-readonly="true" - [value]="progress" + [value]="progress()" (ionChange)="checkChange()" (touchstart)="checkFocus()" (touchend)="seek()" @@ -28,10 +28,10 @@

{{ params.title }}

- {{ +currentTimeSong * 1000 | date : "mm:ss" }} + {{ +currentTimeSong * 1000 | date: "mm:ss" }}
- {{ !player ? "00:00" : (player.duration() * 1000 | date : "mm:ss") }} + {{ !player ? "00:00" : (player.duration() * 1000 | date: "mm:ss") }}
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 }} +

+
+
+

+ {{ subtitle }} +

+
+ +
+ +
- -
- +
+
+ +
+
+} @else { +
+
+
+
-
+ @if (highlighted) { + + {{ highlightedText }} + + } @else { + + + + + + } +
- +
+ @if (title) { +
+

+ {{ 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"