diff --git a/.github/actions/bump-and-tag/package-lock.json b/.github/actions/bump-and-tag/package-lock.json
index 8633d0ecef..1efb933cef 100644
--- a/.github/actions/bump-and-tag/package-lock.json
+++ b/.github/actions/bump-and-tag/package-lock.json
@@ -1238,9 +1238,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "22.8.7",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.7.tgz",
- "integrity": "sha512-LidcG+2UeYIWcMuMUpBKOnryBWG/rnmOHQR5apjn8myTQcx3rinFRn7DcIFhMnS0PPFSC6OafdIKEad0lj6U0Q==",
+ "version": "22.9.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
+ "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1816,9 +1816,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001677",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001677.tgz",
- "integrity": "sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog==",
+ "version": "1.0.30001680",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz",
+ "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==",
"dev": true,
"funding": [
{
@@ -1976,9 +1976,9 @@
}
},
"node_modules/cross-spawn": {
- "version": "7.0.3",
- "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
- "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz",
+ "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2187,9 +2187,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.50",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.50.tgz",
- "integrity": "sha512-eMVObiUQ2LdgeO1F/ySTXsvqvxb6ZH2zPGaMYsWzRDdOddUa77tdmI0ltg+L16UpbWdhPmuF3wIQYyQq65WfZw==",
+ "version": "1.5.55",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.55.tgz",
+ "integrity": "sha512-6maZ2ASDOTBtjt9FhqYPRnbvKU5tjG0IN9SztUOWYw2AzNDNpKJYLJmlK0/En4Hs/aiWnB+JZ+gW19PIGszgKg==",
"dev": true,
"license": "ISC"
},
@@ -2308,9 +2308,9 @@
}
},
"node_modules/es-iterator-helpers": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.1.0.tgz",
- "integrity": "sha512-/SurEfycdyssORP/E+bj4sEu1CWw4EmLDsHynHwSXQ7utgbrMRWW195pTrCjFgFCddf/UkYm3oqKPRq5i8bJbw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.0.tgz",
+ "integrity": "sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2322,6 +2322,7 @@
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"globalthis": "^1.0.4",
+ "gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2",
"has-proto": "^1.0.3",
"has-symbols": "^1.0.3",
@@ -5403,9 +5404,9 @@
}
},
"node_modules/object-inspect": {
- "version": "1.13.2",
- "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
- "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==",
+ "version": "1.13.3",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
+ "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/.github/actions/run-full-stack/action.yml b/.github/actions/run-full-stack/action.yml
index da18810477..924d9c2b89 100644
--- a/.github/actions/run-full-stack/action.yml
+++ b/.github/actions/run-full-stack/action.yml
@@ -9,5 +9,5 @@ runs:
run: |
set -x
export JWT_PRIVATE_KEY="${{ env.JWT_PRIVATE_KEY }}"
- docker compose -f docker-compose.yml down -v
- docker compose -f docker-compose.yml up db data-import backend frontend-static --build -d
+ docker compose -f docker-compose.static.yml down -v
+ docker compose -f docker-compose.static.yml up --build -d
diff --git a/.github/actions/setup-javascript/action.yml b/.github/actions/setup-javascript/action.yml
index f473b82b24..f2128926a1 100644
--- a/.github/actions/setup-javascript/action.yml
+++ b/.github/actions/setup-javascript/action.yml
@@ -6,7 +6,7 @@ runs:
- name: Set up Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
- bun-version: 1.0.11
+ bun-version: latest
- name: Install bun dependencies
shell: bash
working-directory: ./frontend
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
index 5038e937d6..fb7ab358b5 100644
--- a/.github/workflows/commitlint.yml
+++ b/.github/workflows/commitlint.yml
@@ -9,5 +9,5 @@ jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
- - uses: wagoid/commitlint-github-action@v6
+ - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
+ - uses: wagoid/commitlint-github-action@3d28780bbf0365e29b144e272b2121204d5be5f3 # v6
diff --git a/.github/workflows/nightly_scans.yml b/.github/workflows/nightly_scans.yml
index c5dcc3d7ea..eee7f74e67 100644
--- a/.github/workflows/nightly_scans.yml
+++ b/.github/workflows/nightly_scans.yml
@@ -34,6 +34,6 @@ jobs:
uses: SvanBoxel/zaproxy-to-ghas@cfc77481d74a17a4c3d6b753aa9d7abef453d501 # v1.0.2
- name: Upload SARIF file
- uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3
+ uses: github/codeql-action/upload-sarif@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3
with:
sarif_file: results.sarif
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000000..0c739dc226
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,33 @@
+name: Release
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # to be able to publish a GitHub release
+ issues: write # to be able to comment on released issues
+ pull-requests: write # to be able to comment on released pull requests
+ id-token: write # to enable use of OIDC for npm provenance
+ steps:
+ - name: Checkout
+ uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
+ with:
+ fetch-depth: 0
+ - name: Setup Node.js
+ uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
+ with:
+ node-version: "lts/*"
+ - name: Install semantic-release
+ run: npm install -g semantic-release
+ - name: Release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: npx semantic-release
diff --git a/.github/workflows/security_codeql.yml b/.github/workflows/security_codeql.yml
index 0198c133b2..8bd6f307e4 100644
--- a/.github/workflows/security_codeql.yml
+++ b/.github/workflows/security_codeql.yml
@@ -22,7 +22,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3
+ uses: github/codeql-action/init@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3
with:
languages: javascript, python
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -33,4 +33,4 @@ jobs:
queries: +security-extended
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # v3
+ uses: github/codeql-action/analyze@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3
diff --git a/.github/workflows/security_semgrep.yml b/.github/workflows/security_semgrep.yml
index 8e073968a2..5438b6fcf9 100644
--- a/.github/workflows/security_semgrep.yml
+++ b/.github/workflows/security_semgrep.yml
@@ -25,7 +25,7 @@ jobs:
SEMGREP_RULES: "p/default"
- name: Upload SARIF file for GitHub Advanced Security Dashboard
- uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3
+ uses: github/codeql-action/upload-sarif@396bb3e45325a47dd9ef434068033c6d5bb0d11a # v3
with:
sarif_file: ${{ env.SEMGREP_TO_UPLOAD }}
if: always()
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 930bc2da23..05ff838eba 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,16 +12,10 @@ repos:
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
-
- repo: https://github.com/hadolint/hadolint
rev: v2.10.0
hooks:
- id: hadolint
- # We're running black, but doing it via nox session instead - see below
- # - repo: https://github.com/psf/black
- # rev: 22.6.0
- # hooks:
- # - id: black
- repo: https://github.com/pre-commit/mirrors-isort
rev: v5.10.1
hooks:
@@ -46,21 +40,16 @@ repos:
- css
- html
pass_filenames: false
- - repo: local
- hooks:
- id: trufflehog
name: TruffleHog
description: Detect secrets in your data.
- # For running trufflehog locally, use the following:
- # entry: bash -c 'trufflehog git file://. --since-commit HEAD --only-verified --fail'
- # For running trufflehog in docker, use the following entry instead:
- entry: bash -c 'docker run --rm -v "$(pwd):/workdir" -i --rm trufflesecurity/trufflehog:latest git file:///workdir --since-commit HEAD --only-verified --fail'
+ entry: bash -c 'if command -v podman >/dev/null 2>&1; then podman run --rm -v "$(pwd):/workdir" -i trufflesecurity/trufflehog:latest git file:///workdir --since-commit HEAD --only-verified --fail; elif command -v docker >/dev/null 2>&1; then docker run --rm -v "$(pwd):/workdir" -i trufflesecurity/trufflehog:latest git file:///workdir --since-commit HEAD --only-verified --fail; else echo "Neither docker nor podman found. Please install one of them." && exit 1; fi'
language: system
stages: ["pre-commit", "pre-push"]
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.18.0
hooks:
- id: commitlint
- stages: [ commit-msg ]
- additional_dependencies: [ "@commitlint/config-conventional" ]
+ stages: [commit-msg]
+ additional_dependencies: ["@commitlint/config-conventional"]
language_version: 22.8.0
diff --git a/README.md b/README.md
index fbe319f902..cbe9868a2a 100644
--- a/README.md
+++ b/README.md
@@ -87,19 +87,13 @@ docker compose up --build
To run the application using the production server configuration...
```shell
-docker compose up db data-import backend frontend-static --build
+docker compose -f docker-compose.static.yml up --build
````
-To run the application using the minimal initial data set...
-
-```shell
- docker compose --profile data-initial up --build
-```
-
To run the application using the demo data set...
```shell
- docker compose --profile data-demo up --build
+docker compose -f docker-compose.demo.yml up --build
```
diff --git a/backend/data_tools/Pipfile b/backend/data_tools/Pipfile
index c2b2b18fac..946e2d7959 100644
--- a/backend/data_tools/Pipfile
+++ b/backend/data_tools/Pipfile
@@ -6,7 +6,7 @@ name = "pypi"
[packages]
SQLAlchemy = "==2.0.36"
pandas = "==2.2.3"
-json5 = "==0.9.25"
+json5 = "==0.9.28"
psycopg2-binary = "==2.9.10"
cfenv = "==0.5.3"
typing-extensions = "==4.12.2"
@@ -14,7 +14,7 @@ desert = "2022.9.22"
sqlalchemy-continuum = "==1.4.2"
marshmallow-sqlalchemy = "==1.1.0"
marshmallow-enum = "==1.5.1"
-alembic = "==1.13.3"
+alembic = "==1.14.0"
alembic-postgresql-enum = "==1.3.0"
azure-storage-blob = "==12.23.1"
azure-identity = "==1.19.0"
diff --git a/backend/data_tools/Pipfile.lock b/backend/data_tools/Pipfile.lock
index 245455e205..0827cbb6f3 100644
--- a/backend/data_tools/Pipfile.lock
+++ b/backend/data_tools/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "0e6d269778b3fa13b39ee82858159006ff843a8a6b9cfab2e9c7cee41c5ad4ee"
+ "sha256": "8676e9b10cc75886b16dad0f4386a2a7d30500337b5ab1839b77278af3c78463"
},
"pipfile-spec": 6,
"requires": {
@@ -132,12 +132,12 @@
},
"alembic": {
"hashes": [
- "sha256:203503117415561e203aa14541740643a611f641517f0209fcae63e9fa09f1a2",
- "sha256:908e905976d15235fae59c9ac42c4c5b75cfcefe3d27c0fbf7ae15a37715d80e"
+ "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25",
+ "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==1.13.3"
+ "version": "==1.14.0"
},
"alembic-postgresql-enum": {
"hashes": [
@@ -644,12 +644,12 @@
},
"json5": {
"hashes": [
- "sha256:34ed7d834b1341a86987ed52f3f76cd8ee184394906b6e22a1e0deb9ab294e8f",
- "sha256:548e41b9be043f9426776f05df8635a00fe06104ea51ed24b67f908856e151ae"
+ "sha256:1f82f36e615bc5b42f1bbd49dbc94b12563c56408c6ffa06414ea310890e9a6e",
+ "sha256:29c56f1accdd8bc2e037321237662034a7e07921e2b7223281a5ce2c46f0c4df"
],
"index": "pypi",
- "markers": "python_version >= '3.8'",
- "version": "==0.9.25"
+ "markers": "python_full_version >= '3.8.0'",
+ "version": "==0.9.28"
},
"loguru": {
"hashes": [
@@ -952,11 +952,11 @@
},
"packaging": {
"hashes": [
- "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
- "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
+ "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759",
+ "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"
],
"markers": "python_version >= '3.8'",
- "version": "==24.1"
+ "version": "==24.2"
},
"pandas": {
"hashes": [
@@ -1909,11 +1909,11 @@
},
"jedi": {
"hashes": [
- "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd",
- "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"
+ "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0",
+ "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"
],
"markers": "python_version >= '3.6'",
- "version": "==0.19.1"
+ "version": "==0.19.2"
},
"markdown-it-py": {
"hashes": [
@@ -2064,11 +2064,11 @@
},
"packaging": {
"hashes": [
- "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
- "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
+ "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759",
+ "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"
],
"markers": "python_version >= '3.8'",
- "version": "==24.1"
+ "version": "==24.2"
},
"parso": {
"hashes": [
diff --git a/backend/data_tools/tests/docker-compose.yml b/backend/data_tools/tests/docker-compose.yml
index acda2ed160..1382ca0f11 100644
--- a/backend/data_tools/tests/docker-compose.yml
+++ b/backend/data_tools/tests/docker-compose.yml
@@ -4,7 +4,6 @@ services:
db:
image: "postgres:16"
- platform: linux/amd64
container_name: unit-test-db
security_opt:
- no-new-privileges:true # Resolve semgrep https://sg.run/0n8q
diff --git a/backend/ops_api/Pipfile b/backend/ops_api/Pipfile
index 1b7deb6d2d..53f4191a29 100644
--- a/backend/ops_api/Pipfile
+++ b/backend/ops_api/Pipfile
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
-alembic = "==1.13.3"
+alembic = "==1.14.0"
alembic-postgresql-enum = "==1.3.0"
authlib = "==1.3.2"
azure-identity = "==1.19.0"
diff --git a/backend/ops_api/Pipfile.lock b/backend/ops_api/Pipfile.lock
index 59ed975b38..523aaf8d2d 100644
--- a/backend/ops_api/Pipfile.lock
+++ b/backend/ops_api/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "47eadc35be9a7fe271f38bd0f98b1bab07fc82949b69698331c356f7d9ef2ac9"
+ "sha256": "cfba920e45218610ae7e31839169db22d9117c1f90708bb117617b4145cbdbb4"
},
"pipfile-spec": 6,
"requires": {
@@ -18,12 +18,12 @@
"default": {
"alembic": {
"hashes": [
- "sha256:203503117415561e203aa14541740643a611f641517f0209fcae63e9fa09f1a2",
- "sha256:908e905976d15235fae59c9ac42c4c5b75cfcefe3d27c0fbf7ae15a37715d80e"
+ "sha256:99bd884ca390466db5e27ffccff1d179ec5c05c965cfefc0607e69f9e411cb25",
+ "sha256:b00892b53b3642d0b8dbedba234dbf1924b69be83a9a769d5a624b01094e304b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
- "version": "==1.13.3"
+ "version": "==1.14.0"
},
"alembic-postgresql-enum": {
"hashes": [
@@ -79,11 +79,11 @@
},
"blinker": {
"hashes": [
- "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01",
- "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"
+ "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf",
+ "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"
],
- "markers": "python_version >= '3.8'",
- "version": "==1.8.2"
+ "markers": "python_version >= '3.9'",
+ "version": "==1.9.0"
},
"certifi": {
"hashes": [
@@ -659,11 +659,11 @@
},
"packaging": {
"hashes": [
- "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
- "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
+ "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759",
+ "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"
],
"markers": "python_version >= '3.8'",
- "version": "==24.1"
+ "version": "==24.2"
},
"portalocker": {
"hashes": [
@@ -956,11 +956,11 @@
},
"werkzeug": {
"hashes": [
- "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4",
- "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5"
+ "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e",
+ "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"
],
"markers": "python_version >= '3.9'",
- "version": "==3.1.1"
+ "version": "==3.1.3"
}
},
"develop": {
@@ -1133,11 +1133,11 @@
},
"blinker": {
"hashes": [
- "sha256:1779309f71bf239144b9399d06ae925637cf6634cf6bd131104184531bf67c01",
- "sha256:8f77b09d3bf7c795e969e9486f39c2c5e9c39d4ee07424be2bc594ece9642d83"
+ "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf",
+ "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"
],
- "markers": "python_version >= '3.8'",
- "version": "==1.8.2"
+ "markers": "python_version >= '3.9'",
+ "version": "==1.9.0"
},
"click": {
"hashes": [
@@ -1426,11 +1426,11 @@
},
"jedi": {
"hashes": [
- "sha256:cf0496f3651bc65d7174ac1b7d043eff454892c708a87d1b683e57b569927ffd",
- "sha256:e983c654fe5c02867aef4cdfce5a2fbb4a50adc0af145f70504238f18ef5e7e0"
+ "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0",
+ "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"
],
"markers": "python_version >= '3.6'",
- "version": "==0.19.1"
+ "version": "==0.19.2"
},
"jinja2": {
"hashes": [
@@ -1711,11 +1711,11 @@
},
"packaging": {
"hashes": [
- "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002",
- "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"
+ "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759",
+ "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"
],
"markers": "python_version >= '3.8'",
- "version": "==24.1"
+ "version": "==24.2"
},
"parse": {
"hashes": [
@@ -2026,11 +2026,11 @@
},
"werkzeug": {
"hashes": [
- "sha256:8cd39dfbdfc1e051965f156163e2974e52c210f130810e9ad36858f0fd3edad4",
- "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5"
+ "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e",
+ "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"
],
"markers": "python_version >= '3.9'",
- "version": "==3.1.1"
+ "version": "==3.1.3"
},
"yarl": {
"hashes": [
diff --git a/backend/ops_api/ops/document/azure_document_repository.py b/backend/ops_api/ops/document/azure_document_repository.py
index b3f91a7df1..7d4af499eb 100644
--- a/backend/ops_api/ops/document/azure_document_repository.py
+++ b/backend/ops_api/ops/document/azure_document_repository.py
@@ -74,7 +74,14 @@ def generate_account_sas_url(account_name, account_key, expiry_hours=1):
)
# Construct the SAS URL
- sas_url = f"https://{account_name}.blob.core.windows.net/?{sas_token}"
+ DEFAULT_DEV_OPS_URL = "https://dev.ops.opre.acf.gov"
+ OPS_URL = (
+ DEFAULT_DEV_OPS_URL
+ if "localhost" in current_app.config.get("OPS_FRONTEND_URL")
+ else current_app.config.get("OPS_FRONTEND_URL")
+ )
+ # https://dev.ops.opre.acf.gov/?{sas_token} in dev
+ sas_url = f"{OPS_URL}/?{sas_token}"
return sas_url
except SasUrlGenerationError as e:
diff --git a/backend/ops_api/ops/document/document_gateway.py b/backend/ops_api/ops/document/document_gateway.py
index 599dddce45..159c9455f7 100644
--- a/backend/ops_api/ops/document/document_gateway.py
+++ b/backend/ops_api/ops/document/document_gateway.py
@@ -1,5 +1,4 @@
from flask import Config
-from flask_jwt_extended import current_user
from ops_api.ops.document.azure_document_repository import AzureDocumentRepository
from ops_api.ops.document.document_repository import DocumentRepository
@@ -16,11 +15,7 @@ def __init__(self, config: Config) -> None:
# Validate and register providers with the factory
self.register_providers()
- # Select provider based on the current user
- if current_user.id >= 500: # Users with id 5xx are test users
- self.provider = DocumentProviders.fake.name
- else:
- self.provider = DocumentProviders.azure.name
+ self.provider = DocumentProviders.azure.name
def register_providers(self) -> None:
"""
diff --git a/backend/ops_api/tests/docker-compose.yml b/backend/ops_api/tests/docker-compose.yml
index 95cc9c6cac..1e18698e51 100644
--- a/backend/ops_api/tests/docker-compose.yml
+++ b/backend/ops_api/tests/docker-compose.yml
@@ -1,7 +1,6 @@
services:
unittest_db:
image: "postgres:16"
- platform: linux/amd64
container_name: unit-test-db
command: -c 'max_connections=400'
security_opt:
@@ -24,7 +23,6 @@ services:
build:
context: ../../../backend
dockerfile: Dockerfile.data-tools
- platform: linux/amd64
container_name: pytest-data-import
environment:
- ENV=pytest
diff --git a/docker-compose.demo.yml b/docker-compose.demo.yml
new file mode 100644
index 0000000000..d1ea53dbbd
--- /dev/null
+++ b/docker-compose.demo.yml
@@ -0,0 +1,90 @@
+services:
+
+ db:
+ image: "postgres:16"
+ container_name: ops-db
+ security_opt:
+ - no-new-privileges:true # Resolve semgrep https://sg.run/0n8q
+ environment:
+ - POSTGRES_PASSWORD=local_password
+ read_only: true # Resolve semgrep https://sg.run/e4JE
+ tmpfs: /var/run/postgresql/
+ volumes:
+ - ./backend/data_tools/ops_db_sql_init:/docker-entrypoint-initdb.d
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ disable-users:
+ build:
+ context: ./backend/
+ dockerfile: Dockerfile.data-tools
+ container_name: disable-users
+ environment:
+ - ENV=local
+ - SQLALCHEMY_DATABASE_URI=postgresql://ops:ops@db:5432/postgres
+ command: ["/home/app/.venv/bin/python", "./data_tools/src/disable_users/disable_users.py"]
+ depends_on:
+ db:
+ condition: service_healthy
+ data-import:
+ condition: service_completed_successfully
+
+ frontend:
+ build:
+ context: ./frontend/
+ dockerfile: Dockerfile
+ environment:
+ - REACT_APP_BACKEND_DOMAIN=http://localhost:8080
+ - VITE_BACKEND_DOMAIN=http://localhost:8080
+ container_name: ops-frontend-demo
+ ports:
+ - "3000:3000"
+ depends_on:
+ - backend
+ volumes:
+ - ./frontend/src:/home/app/src
+
+ backend:
+ build:
+ context: ./backend/
+ dockerfile: Dockerfile.ops-api
+ container_name: ops-backend-demo
+ ports:
+ - "8080:8080"
+ command: /bin/sh -c " . .venv/bin/activate && python -m flask run --debug --host=0.0.0.0 --port=8080"
+ environment:
+ - JWT_PRIVATE_KEY
+ - JWT_PUBLIC_KEY
+ - OPS_CONFIG=environment/local/container.py
+ volumes:
+ - ./backend/ops_api/ops:/home/app/ops_api/ops
+ depends_on:
+ db:
+ condition: service_healthy
+ data-import:
+ condition: service_completed_successfully
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:8080" ]
+ interval: 10s
+ timeout: 10s
+ retries: 10
+
+ data-import:
+ build:
+ context: ./backend/
+ dockerfile: Dockerfile.data-tools
+ container_name: ops-data-demo
+ environment:
+ - ENV=local
+ - SQLALCHEMY_DATABASE_URI=postgresql://ops:ops@db:5432/postgres
+ command: /bin/sh -c "./data_tools/scripts/import_test_data.sh && ./data_tools/scripts/demo_data.sh"
+ volumes:
+ - ./backend/ops_api:/home/app/ops_api
+ depends_on:
+ db:
+ condition: service_healthy
diff --git a/docker-compose.static.yml b/docker-compose.static.yml
new file mode 100644
index 0000000000..2250c7f241
--- /dev/null
+++ b/docker-compose.static.yml
@@ -0,0 +1,91 @@
+services:
+
+ db:
+ image: "postgres:16"
+ container_name: ops-db
+ security_opt:
+ - no-new-privileges:true # Resolve semgrep https://sg.run/0n8q
+ environment:
+ - POSTGRES_PASSWORD=local_password
+ read_only: true # Resolve semgrep https://sg.run/e4JE
+ tmpfs: /var/run/postgresql/
+ volumes:
+ - ./backend/data_tools/ops_db_sql_init:/docker-entrypoint-initdb.d
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ data-import:
+ build:
+ context: ./backend/
+ dockerfile: Dockerfile.data-tools
+ container_name: ops-data-import
+ environment:
+ - ENV=local
+ - SQLALCHEMY_DATABASE_URI=postgresql://ops:ops@db:5432/postgres
+ command: /bin/sh -c "./data_tools/scripts/import_test_data.sh"
+ volumes:
+ # See below for an explanation of this volume. The same reasoning applies,
+ # but in this case it's so we can run new migrations immediately without
+ # having to rebuild the migration container.
+ - ./backend/ops_api:/home/app/ops_api
+ depends_on:
+ db:
+ condition: service_healthy
+
+ disable-users:
+ build:
+ context: ./backend/
+ dockerfile: Dockerfile.data-tools
+ container_name: disable-users
+ environment:
+ - ENV=local
+ - SQLALCHEMY_DATABASE_URI=postgresql://ops:ops@db:5432/postgres
+ command: ["/home/app/.venv/bin/python", "./data_tools/src/disable_users/disable_users.py"]
+ depends_on:
+ db:
+ condition: service_healthy
+ data-import:
+ condition: service_completed_successfully
+
+ backend:
+ build:
+ context: ./backend/
+ dockerfile: Dockerfile.ops-api
+ container_name: ops-backend
+ ports:
+ - "8080:8080"
+ command: /bin/sh -c " . .venv/bin/activate && python -m flask run --debug --host=0.0.0.0 --port=8080"
+ environment:
+ - JWT_PRIVATE_KEY
+ - JWT_PUBLIC_KEY
+ - OPS_CONFIG=environment/local/container.py
+ volumes:
+ - ./backend/ops_api/ops:/home/app/ops_api/ops
+ depends_on:
+ db:
+ condition: service_healthy
+ data-import:
+ condition: service_completed_successfully
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:8080" ]
+ interval: 10s
+ timeout: 10s
+ retries: 10
+
+ frontend-static:
+ build:
+ context: ./frontend/
+ dockerfile: Dockerfile.azure
+ args:
+ VITE_BACKEND_DOMAIN: http://localhost:8080
+ MODE: dev # set this to production to create a production build
+ container_name: ops-frontend
+ ports:
+ - "3000:3000"
+ depends_on:
+ - backend
diff --git a/docker-compose.yml b/docker-compose.yml
index cdbebf39cc..548c221cdc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,12 +1,7 @@
services:
db:
- profiles:
- - '' # This is the default profile, which is used for development
- - data-initial # This profile is used to initialize the database with initial data
- - data-demo # This profile is used to initialize the database with demo data
image: "postgres:16"
- platform: linux/amd64
container_name: ops-db
security_opt:
- no-new-privileges:true # Resolve semgrep https://sg.run/0n8q
@@ -25,12 +20,9 @@ services:
retries: 5
data-import:
- profiles:
- - '' # This is the default profile, which is used for development
build:
context: ./backend/
dockerfile: Dockerfile.data-tools
- platform: linux/amd64
container_name: ops-data-import
environment:
- ENV=local
@@ -46,12 +38,9 @@ services:
condition: service_healthy
disable-users:
- profiles:
- - '' # This is the default profile, which is used for development
build:
context: ./backend/
dockerfile: Dockerfile.data-tools
- platform: linux/amd64
container_name: disable-users
environment:
- ENV=local
@@ -64,12 +53,9 @@ services:
condition: service_completed_successfully
backend:
- profiles:
- - '' # This is the default profile, which is used for development
build:
context: ./backend/
dockerfile: Dockerfile.ops-api
- platform: linux/amd64
container_name: ops-backend
ports:
- "8080:8080"
@@ -92,12 +78,9 @@ services:
retries: 10
frontend:
- profiles:
- - '' # This is the default profile, which is used for development
build:
context: ./frontend/
dockerfile: Dockerfile
- platform: linux/amd64
container_name: ops-frontend
environment:
- REACT_APP_BACKEND_DOMAIN=http://localhost:8080
@@ -108,151 +91,3 @@ services:
- backend
volumes:
- ./frontend/src:/home/app/src
-
- frontend-static:
- profiles:
- - static
- build:
- context: ./frontend/
- dockerfile: Dockerfile.azure
- args:
- VITE_BACKEND_DOMAIN: http://localhost:8080
- MODE: dev # set this to production to create a production build
- platform: linux/amd64
- container_name: ops-frontend-static
- ports:
- - "3000:3000"
- depends_on:
- - backend
-
-
- # The following services are used to initialize the database with initial data using the data-initial profile.
-
- frontend-static-initial:
- profiles:
- - data-initial
- build:
- context: ./frontend/
- dockerfile: Dockerfile.azure
- args:
- VITE_BACKEND_DOMAIN: http://localhost:8080
- MODE: dev # set this to production to create a production build
- platform: linux/amd64
- container_name: ops-frontend-static-initial
- ports:
- - "3000:3000"
- depends_on:
- - backend-initial
-
-
- backend-initial:
- profiles:
- - data-initial
- build:
- context: ./backend/
- dockerfile: Dockerfile.ops-api
- platform: linux/amd64
- container_name: ops-backend-initial
- ports:
- - "8080:8080"
- command: /bin/sh -c " . .venv/bin/activate && python -m flask run --debug --host=0.0.0.0 --port=8080"
- environment:
- - JWT_PRIVATE_KEY
- - JWT_PUBLIC_KEY
- - OPS_CONFIG=environment/local/container.py
- volumes:
- - ./backend/ops_api/ops:/home/app/ops_api/ops
- depends_on:
- db:
- condition: service_healthy
- data-initial:
- condition: service_completed_successfully
- healthcheck:
- test: [ "CMD", "curl", "-f", "http://localhost:8080" ]
- interval: 10s
- timeout: 10s
- retries: 10
-
- data-initial:
- profiles:
- - data-initial
- build:
- context: ./backend/
- dockerfile: Dockerfile.data-tools
- platform: linux/amd64
- container_name: ops-data-initial
- environment:
- - ENV=local
- - SQLALCHEMY_DATABASE_URI=postgresql://ops:ops@db:5432/postgres
- command: /bin/sh -c "./data_tools/scripts/initial_data.sh"
- volumes:
- - ./backend/ops_api:/home/app/ops_api
- depends_on:
- db:
- condition: service_healthy
-
- # The following services are used to initialize the database with demo data using the data-demo profile.
-
- frontend-demo:
- profiles:
- - data-demo
- build:
- context: ./frontend/
- dockerfile: Dockerfile
- environment:
- - REACT_APP_BACKEND_DOMAIN=http://localhost:8080
- - VITE_BACKEND_DOMAIN=http://localhost:8080
- platform: linux/amd64
- container_name: ops-frontend-demo
- ports:
- - "3000:3000"
- depends_on:
- - backend-demo
- volumes:
- - ./frontend/src:/home/app/src
-
- backend-demo:
- profiles:
- - data-demo
- build:
- context: ./backend/
- dockerfile: Dockerfile.ops-api
- platform: linux/amd64
- container_name: ops-backend-demo
- ports:
- - "8080:8080"
- command: /bin/sh -c " . .venv/bin/activate && python -m flask run --debug --host=0.0.0.0 --port=8080"
- environment:
- - JWT_PRIVATE_KEY
- - JWT_PUBLIC_KEY
- - OPS_CONFIG=environment/local/container.py
- volumes:
- - ./backend/ops_api/ops:/home/app/ops_api/ops
- depends_on:
- db:
- condition: service_healthy
- data-demo:
- condition: service_completed_successfully
- healthcheck:
- test: [ "CMD", "curl", "-f", "http://localhost:8080" ]
- interval: 10s
- timeout: 10s
- retries: 10
-
- data-demo:
- profiles:
- - data-demo
- build:
- context: ./backend/
- dockerfile: Dockerfile.data-tools
- platform: linux/amd64
- container_name: ops-data-demo
- environment:
- - ENV=local
- - SQLALCHEMY_DATABASE_URI=postgresql://ops:ops@db:5432/postgres
- command: /bin/sh -c "./data_tools/scripts/import_test_data.sh && ./data_tools/scripts/demo_data.sh"
- volumes:
- - ./backend/ops_api:/home/app/ops_api
- depends_on:
- db:
- condition: service_healthy
diff --git a/docs/git-branching-and-releasing.md b/docs/git-branching-and-releasing.md
new file mode 100644
index 0000000000..b3a107f902
--- /dev/null
+++ b/docs/git-branching-and-releasing.md
@@ -0,0 +1,43 @@
+# OPRE-OPS Branching Strategy
+
+The OPRE Team currently uses the following branching strategy:
+### main
+* `main` is the default and primary branch. This will always be the most up-to-date released code base.
+* `main` currently has branch protections in place, and requires a `pull request` with at least `2` approvals from someone on the `dev team`.
+
+### Feature Branches
+* Features should branch from `main` and utilize a naming format of `OPS-{Issue#}/{Feature_Name}`, example: `OPS-522/CAN_Details_Page`.
+
+```mermaid
+---
+title: OPRE-OPS Git Branching
+---
+%%{init: {'gitGraph': {'showBranches': true, 'showCommitLabel':true,'mainBranchOrder': 4}} }%%
+gitGraph
+ commit id: "initial commit"
+ commit
+ branch OPS-5xx/New_Feature_A
+ checkout OPS-5xx/New_Feature_A
+ commit
+ commit
+ checkout main
+ merge OPS-5xx/New_Feature_A
+ checkout main
+ commit
+ branch OPS-6xx/New_Feature_B
+ checkout OPS-6xx/New_Feature_B
+ commit
+ commit
+ commit
+ checkout main
+ merge OPS-6xx/New_Feature_B
+```
+
+# OPRE-OPS Release Strategy
+
+The OPRE Team currently uses the following release strategy:
+
+- When a PR is merged to `main` the GitHub Action `release.yml` will automatically create a new release in
+GitHub with next version number.
+- The `main` branch will be tagged with the new version number.
+- The release will be published to the [GitHub Releases page](https://github.com/HHS/OPRE-OPS/releases).
diff --git a/docs/git-branching.md b/docs/git-branching.md
deleted file mode 100644
index b2a298f51f..0000000000
--- a/docs/git-branching.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# OPRE-OPS Branching Strategy
-
-The OPRE Team currently uses the following branching strategy:
-### main
-* `main` is the default and primary branch. This will always be the most up-to-date released code base.
-* `main` currently has branch protections in place, and requires a `pull request` with at least `1` approval from someone on the `dev team`.
-
-### Release Branches
-* `development`, `staging`, `production` are reserved branches cooresponding the their respective release environments. Any commit will trigger a release to that specific environment (Space) within Cloud.gov.
-* If you want to Cloud.gov, simply `push` to one of the environment branches. You should follow the standard progression though of `development` --> `staging` --> `production`.
-* `staging` and `production` will have protections in place ensuring a release was processed to their lower tier environments prior to allowing a `push`.
-
-### Feature Branches
-* Features should branch from `main` and utilize a naming format of `OPS-{Issue#}_{Feature_Name}`, example: `OPS-522_CAN_Details_Page`.
-* During colaboration, it's okay to branch from someone else's branch instead of `main`.
-* Always try to do new work in a feature branch, avoid using generic branches like `tim_custom_testing_branch`. Keep branches specific to a feature or subset of changes.
-
-```mermaid
----
-title: OPRE-OPS Git Branching
----
-%%{init: {'gitGraph': {'showBranches': true, 'showCommitLabel':true,'mainBranchOrder': 4}} }%%
-gitGraph
- commit id: "initial commit"
- commit
- branch OPS-5xx_New_Feature_A order: 5
- checkout OPS-5xx_New_Feature_A
- commit
- commit
- checkout main
- merge OPS-5xx_New_Feature_A
- commit id: "release" tag: "v1.0.3"
- branch development order: 3
- checkout development
- commit id: "dev release" type: HIGHLIGHT
-
-
- branch staging order: 2
- checkout staging
- commit id: "staging release" type: HIGHLIGHT
-
- branch production order: 1
- checkout production
- commit id: "prod release" type: HIGHLIGHT
-
- checkout main
- commit
- branch OPS-6xx_New_Feature_B order: 6
- checkout OPS-6xx_New_Feature_B
- commit
- commit
- commit
- checkout development
- merge OPS-6xx_New_Feature_B id: "dev preview"
- checkout main
- merge OPS-6xx_New_Feature_B
-
- commit id: "release" tag: "v1.0.4"
-
-```
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 89c401619a..d36bfd05e2 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -7,7 +7,7 @@ WORKDIR /home/app
COPY --chown=app:app ./package.json ./bun.lockb /home/app/
-RUN bun install
+RUN bun install --production --frozen-lockfile
COPY --chown=app:app index.html /home/app/
COPY --chown=app:app src /home/app/src
diff --git a/frontend/bun.lockb b/frontend/bun.lockb
index 19a2bc6cc4..e94d7fae4f 100755
Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ
diff --git a/frontend/cypress/e2e/canDetail.cy.js b/frontend/cypress/e2e/canDetail.cy.js
index 5542caa246..31081bb55a 100644
--- a/frontend/cypress/e2e/canDetail.cy.js
+++ b/frontend/cypress/e2e/canDetail.cy.js
@@ -11,7 +11,7 @@ afterEach(() => {
});
describe("CAN detail page", () => {
- it("loads", () => {
+ it("shows relevant CAN data", () => {
cy.visit("/cans/502/");
cy.get("h1").should("contain", "G99PHS9"); // heading
cy.get("p").should("contain", "SSRD - 5 Years"); // sub-heading
@@ -19,4 +19,19 @@ describe("CAN detail page", () => {
cy.get("span").should("contain", "Director Derrek"); // division director
cy.get("span").should("contain", "Program Support"); // portfolio
});
+ it("shows the CAN Spending page", () => {
+ cy.visit("/cans/504/spending");
+ cy.get("#fiscal-year-select").select("2021");
+ cy.get("h1").should("contain", "G994426"); // heading
+ cy.get("p").should("contain", "HS - 5 Years"); // sub-heading
+ // should contain the budget line table
+ cy.get("table").should("exist");
+ // table should have more than 1 row
+ cy.get("tbody").children().should("have.length.greaterThan", 1);
+ // switch to a different fiscal year
+ cy.get("#fiscal-year-select").select("2022");
+ // table should not exist
+ cy.get("tbody").should("not.exist");
+ cy.get("p").should("contain", "No budget lines have been added to this CAN.");
+ });
});
diff --git a/frontend/cypress/e2e/uploadDocument.cy.js b/frontend/cypress/e2e/uploadDocument.cy.js
index ad75911db5..620a563d9f 100644
--- a/frontend/cypress/e2e/uploadDocument.cy.js
+++ b/frontend/cypress/e2e/uploadDocument.cy.js
@@ -14,7 +14,7 @@ it("should loads", () => {
cy.get("h1").should("have.text", "Temporary Upload Document Page");
});
-it("should create a document database record and upload to in memory storage", () => {
+it.skip("should create a document database record and upload to in memory storage", () => {
// Entering an Agreement ID in the Upload Document section
cy.get('#agreement-id-upload').type('1');
// Selecting a file
@@ -47,7 +47,7 @@ it("should create a document database record and upload to in memory storage", (
})
});
-it("Should download document in memory storage and verify logs", () => {
+it.skip("Should download document in memory storage and verify logs", () => {
// set up spy on console.log
let logSpy;
cy.window().then((win) => {
diff --git a/frontend/package.json b/frontend/package.json
index 6e3f033d39..e5c4f1776f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,113 +1,114 @@
{
- "name": "opre-ops",
- "version": "1.0.1",
- "license": "CC0-1.0",
- "private": true,
- "type": "module",
- "dependencies": {
- "@azure/storage-blob": "12.25.0",
- "@eslint/compat": "1.2.2",
- "@eslint/js": "9.14.0",
- "@fortawesome/fontawesome-svg-core": "6.5.2",
- "@fortawesome/free-regular-svg-icons": "6.5.2",
- "@fortawesome/free-solid-svg-icons": "6.5.2",
- "@fortawesome/react-fontawesome": "0.2.2",
- "@nivo/bar": "0.87.0",
- "@nivo/core": "0.87.0",
- "@nivo/pie": "0.87.0",
- "@reduxjs/toolkit": "2.3.0",
- "@uswds/uswds": "3.9.0",
- "@vitejs/plugin-react": "4.3.3",
- "axios": "1.7.7",
- "clsx": "2.1.1",
- "crypto-random-string": "5.0.0",
- "eslint": "9.14.0",
- "eslint-config-prettier": "9.1.0",
- "eslint-plugin-cypress": "4.1.0",
- "eslint-plugin-import": "2.31.0",
- "eslint-plugin-jest": "28.8.3",
- "eslint-plugin-jsx-a11y": "6.10.2",
- "eslint-plugin-prettier": "5.2.1",
- "eslint-plugin-react": "7.37.2",
- "eslint-plugin-react-hooks": "5.0.0",
- "eslint-plugin-react-refresh": "0.4.14",
- "eslint-plugin-testing-library": "6.4.0",
- "jose": "5.9.6",
- "js-cookie": "3.0.5",
- "jsdom": "25.0.1",
- "jwt-decode": "4.0.0",
- "lodash": "4.17.21",
- "react": "18.3.1",
- "react-currency-format": "1.1.0",
- "react-dom": "18.3.1",
- "react-markdown": "9.0.1",
- "react-modal": "3.16.1",
- "react-redux": "9.1.2",
- "react-router-dom": "6.27.0",
- "react-select": "5.8.2",
- "react-slider": "2.0.6",
- "sass": "1.80.6",
- "sass-loader": "16.0.3",
- "styled-components": "6.1.13",
- "vest": "5.4.3",
- "vite": "5.4.10",
- "vite-jsconfig-paths": "2.0.1",
- "vite-plugin-babel-macros": "1.0.6",
- "vite-plugin-eslint": "1.8.1",
- "vite-plugin-svgr": "4.3.0"
- },
- "overrides": {
- "rollup": "4.24.4"
- },
- "devDependencies": {
- "@testing-library/jest-dom": "6.6.3",
- "@testing-library/react": "16.0.1",
- "@testing-library/user-event": "14.5.2",
- "@types/testing-library__jest-dom": "6.0.0",
- "@types/testing-library__react": "10.2.0",
- "@vitest/coverage-istanbul": "2.1.4",
- "@vitest/ui": "2.1.4",
- "axe-core": "4.10.2",
- "cypress": "13.15.2",
- "cypress-axe": "1.5.0",
- "cypress-localstorage-commands": "2.2.6",
- "globals": "15.11.0",
- "history": "5.3.0",
- "msw": "2.6.0",
- "prettier": "3.3.3",
- "redux-mock-store": "1.5.5",
- "@uswds/compile": "1.2.0",
- "vitest": "2.1.4"
- },
- "scripts": {
- "start": "vite",
- "start:debug": "vite --inspect=0.0.0.0:9229",
- "build": "vite build",
- "test": "vitest",
- "test:coverage": "vitest --coverage",
- "test:ui": "vitest --ui --coverage.enabled=true",
- "test:e2e:interactive": "cypress open --config-file ./cypress.config.js",
- "test:e2e": "cypress run --config-file ./cypress.config.js --headless",
- "test:e2e:debug": "DEBUG=cypress:* cypress run --config-file ./cypress.config.js --headless",
- "lint": "eslint './src/**'",
- "cypress:open": "cypress open",
- "uswds:update": "bunx gulp compile"
- },
- "browserslist": {
- "production": [
- ">0.2%",
- "not dead",
- "not op_mini all"
- ],
- "development": [
- "last 1 chrome version",
- "last 1 firefox version",
- "last 1 safari version"
- ]
- },
- "babelMacros": {
- "fontawesome-svg-core": {
- "license": "free"
- }
+ "name": "opre-ops",
+ "version": "1.0.1",
+ "license": "CC0-1.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "@azure/storage-blob": "12.25.0",
+ "@eslint/compat": "1.2.2",
+ "@eslint/js": "9.14.0",
+ "@fortawesome/fontawesome-svg-core": "6.5.2",
+ "@fortawesome/free-regular-svg-icons": "6.5.2",
+ "@fortawesome/free-solid-svg-icons": "6.5.2",
+ "@fortawesome/react-fontawesome": "0.2.2",
+ "@nivo/bar": "0.87.0",
+ "@nivo/core": "0.87.0",
+ "@nivo/pie": "0.87.0",
+ "@reduxjs/toolkit": "2.3.0",
+ "@uswds/uswds": "3.9.0",
+ "@vitejs/plugin-react": "4.3.3",
+ "axios": "1.7.7",
+ "clsx": "2.1.1",
+ "crypto-random-string": "5.0.0",
+ "eslint": "9.14.0",
+ "eslint-config-prettier": "9.1.0",
+ "eslint-plugin-cypress": "4.1.0",
+ "eslint-plugin-import": "2.31.0",
+ "eslint-plugin-jest": "28.9.0",
+ "eslint-plugin-jsx-a11y": "6.10.2",
+ "eslint-plugin-prettier": "5.2.1",
+ "eslint-plugin-react": "7.37.2",
+ "eslint-plugin-react-hooks": "5.0.0",
+ "eslint-plugin-react-refresh": "0.4.14",
+ "eslint-plugin-testing-library": "6.4.0",
+ "jose": "5.9.6",
+ "js-cookie": "3.0.5",
+ "jsdom": "25.0.1",
+ "jwt-decode": "4.0.0",
+ "lodash": "4.17.21",
+ "react": "18.3.1",
+ "react-currency-format": "1.1.0",
+ "react-dom": "18.3.1",
+ "react-markdown": "9.0.1",
+ "react-modal": "3.16.1",
+ "react-redux": "9.1.2",
+ "react-router-dom": "6.28.0",
+ "react-select": "5.8.3",
+ "react-slider": "2.0.6",
+ "sass": "1.80.6",
+ "sass-loader": "16.0.3",
+ "styled-components": "6.1.13",
+ "vest": "5.4.4",
+ "vite": "5.4.11",
+ "vite-jsconfig-paths": "2.0.1",
+ "vite-plugin-babel-macros": "1.0.6",
+ "vite-plugin-eslint": "1.8.1",
+ "vite-plugin-svgr": "4.3.0"
+ },
+ "overrides": {
+ "rollup": "4.25.0"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "6.6.3",
+ "@testing-library/react": "16.0.1",
+ "@testing-library/user-event": "14.5.2",
+ "@types/testing-library__jest-dom": "6.0.0",
+ "@types/testing-library__react": "10.2.0",
+ "@uswds/compile": "1.2.0",
+ "@vitest/coverage-istanbul": "2.1.4",
+ "@vitest/ui": "2.1.4",
+ "axe-core": "4.10.2",
+ "cypress": "13.15.2",
+ "cypress-axe": "1.5.0",
+ "cypress-localstorage-commands": "2.2.6",
+ "globals": "15.12.0",
+ "history": "5.3.0",
+ "msw": "2.6.4",
+ "prettier": "3.3.3",
+ "redux-mock-store": "1.5.5",
+ "semantic-release": "24.2.0",
+ "vitest": "2.1.4"
+ },
+ "scripts": {
+ "start": "vite",
+ "start:debug": "vite --inspect=0.0.0.0:9229",
+ "build": "vite build",
+ "test": "vitest",
+ "test:coverage": "vitest --coverage",
+ "test:ui": "vitest --ui --coverage.enabled=true",
+ "test:e2e:interactive": "cypress open --config-file ./cypress.config.js",
+ "test:e2e": "cypress run --config-file ./cypress.config.js --headless",
+ "test:e2e:debug": "DEBUG=cypress:* cypress run --config-file ./cypress.config.js --headless",
+ "lint": "eslint './src/**'",
+ "cypress:open": "cypress open",
+ "uswds:update": "bunx gulp compile"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "babelMacros": {
+ "fontawesome-svg-core": {
+ "license": "free"
}
+ }
}
diff --git a/frontend/src/components/Agreements/Documents/Document.test.js b/frontend/src/components/Agreements/Documents/Document.test.js
index 3b04a1712a..59e5218e6a 100644
--- a/frontend/src/components/Agreements/Documents/Document.test.js
+++ b/frontend/src/components/Agreements/Documents/Document.test.js
@@ -58,7 +58,7 @@ describe("processUploading", () => {
});
it("should upload to Azure Blob Storage", async () => {
- const sasUrl = "https://mock.blob.core.windows.net";
+ const sasUrl = "https://mock.ops.opre.acf.gov";
await processUploading(sasUrl, uuid, file, agreementId, mockUploadDocumentToBlob, mockUploadDocumentToBlob);
@@ -77,7 +77,7 @@ describe("processUploading", () => {
});
it("should handle errors gracefully", async () => {
- const sasUrl = "https://mock.blob.core.windows.net";
+ const sasUrl = "https://mock.ops.opre.acf.gov";
mockUploadDocumentToBlob.mockRejectedValue(new Error("Upload failed"));
diff --git a/frontend/src/components/Agreements/Documents/Documents.constants.js b/frontend/src/components/Agreements/Documents/Documents.constants.js
index dd24ca3421..0a58232d37 100644
--- a/frontend/src/components/Agreements/Documents/Documents.constants.js
+++ b/frontend/src/components/Agreements/Documents/Documents.constants.js
@@ -10,7 +10,7 @@ export const DOCUMENT_TYPES = [
export const VALID_EXTENSIONS = ["pdf", "doc", "docx", "xls", "xlsx"];
-export const DOCUMENT_CONTAINER_NAME = "documents";
+export const DOCUMENT_CONTAINER_NAME = "docs";
export const ALLOWED_FAKE_HOSTS = "FakeDocumentRepository";
-export const ALLOWED_HOSTS = "blob.core.windows.net";
+export const ALLOWED_HOSTS = "ops.opre.acf.gov";
diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CABBudgetLineTable.constants.js b/frontend/src/components/CANs/CANBudgetLineTable/CABBudgetLineTable.constants.js
new file mode 100644
index 0000000000..c141ef13b7
--- /dev/null
+++ b/frontend/src/components/CANs/CANBudgetLineTable/CABBudgetLineTable.constants.js
@@ -0,0 +1 @@
+export const TABLE_HEADERS = ["BL ID #", "Agreement", "Obligate By", "FY", "Total", "% of CAN", "Status"];
diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.jsx
new file mode 100644
index 0000000000..a52d7cf4eb
--- /dev/null
+++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.jsx
@@ -0,0 +1,50 @@
+import { formatDateNeeded } from "../../../helpers/utils";
+import Table from "../../UI/Table";
+import { TABLE_HEADERS } from "./CABBudgetLineTable.constants";
+import CANBudgetLineTableRow from "./CANBudgetLineTableRow";
+/**
+ * @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine
+ */
+
+/**
+ * @typedef {Object} CANBudgetLineTableProps
+ * @property {BudgetLine[]} budgetLines
+ */
+
+/**
+ * @component - The CAN Budget Line Table.
+ * @param {CANBudgetLineTableProps} props
+ * @returns {JSX.Element} - The component JSX.
+ */
+const CANBudgetLineTable = ({ budgetLines }) => {
+ if (budgetLines.length === 0) {
+ return
No budget lines have been added to this CAN.
;
+ }
+
+ return (
+
+ {budgetLines.map((budgetLine) => (
+
+ ))}
+
+ );
+};
+
+export default CANBudgetLineTable;
diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.test.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.test.jsx
new file mode 100644
index 0000000000..8e4402f829
--- /dev/null
+++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTable.test.jsx
@@ -0,0 +1,33 @@
+import { render, screen } from "@testing-library/react";
+import { Provider } from "react-redux";
+import CANBudgetLineTable from "./CANBudgetLineTable";
+import store from "../../../store";
+import { budgetLine } from "../../../tests/data";
+
+describe("CANBudgetLineTable", () => {
+ const mockBudgetLines = [
+ { ...budgetLine, status: "Approved", amount: 1000 },
+ { ...budgetLine, status: "Pending", amount: 2000 }
+ ];
+
+ it("renders 'No budget lines have been added to this CAN.' when there are no budget lines", () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText("No budget lines have been added to this CAN.")).toBeInTheDocument();
+ });
+
+ it("renders table with budget lines", () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText("Approved")).toBeInTheDocument();
+ expect(screen.getByText("Pending")).toBeInTheDocument();
+ expect(screen.getByText("$1,000.00")).toBeInTheDocument();
+ expect(screen.getByText("$2,000.00")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.jsx
new file mode 100644
index 0000000000..68cc051516
--- /dev/null
+++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.jsx
@@ -0,0 +1,225 @@
+import { faClock } from "@fortawesome/free-regular-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import CurrencyFormat from "react-currency-format";
+import {
+ formatDateToMonthDayYear,
+ totalBudgetLineAmountPlusFees,
+ totalBudgetLineFeeAmount
+} from "../../../helpers/utils";
+import useGetUserFullNameFromId from "../../../hooks/user.hooks";
+import TableRowExpandable from "../../UI/TableRowExpandable";
+import {
+ changeBgColorIfExpanded,
+ expandedRowBGColor,
+ removeBorderBottomIfExpanded
+} from "../../UI/TableRowExpandable/TableRowExpandable.helpers";
+import { useTableRow } from "../../UI/TableRowExpandable/TableRowExpandable.hooks";
+import TableTag from "../../UI/TableTag";
+import { useChangeRequestsForTooltip } from "../../../hooks/useChangeRequests.hooks";
+
+/**
+ * @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine
+ */
+
+/**
+ * @typedef {Object} CANBudgetLineTableRowProps
+ * @property {BudgetLine} budgetLine
+ * @property {number} blId
+ * @property {string} agreementName - TODO
+ * @property {string} obligateDate
+ * @property {number | string } fiscalYear
+ * @property {number} amount
+ * @property {number} fee
+ * @property {number} percentOfCAN - TODO
+ * @property {string} status
+ * @property {boolean} inReview
+ * @property {number} creatorId
+ * @property {string} creationDate
+ * @property {string} procShopCode - TODO
+ * @property {number} procShopFeePercentage
+ * @property {string} notes
+ */
+
+/**
+ * @component - The CAN Budget Line Table.
+ * @param {CANBudgetLineTableRowProps} props
+ * @returns {JSX.Element} - The component JSX.
+ */
+const CANBudgetLineTableRow = ({
+ budgetLine,
+ blId,
+ agreementName,
+ obligateDate,
+ fiscalYear,
+ amount,
+ fee,
+ percentOfCAN,
+ status,
+ inReview,
+ creatorId,
+ creationDate,
+ procShopCode,
+ procShopFeePercentage,
+ notes
+}) => {
+ const lockedMessage = useChangeRequestsForTooltip(budgetLine);
+ const { isExpanded, setIsRowActive, setIsExpanded } = useTableRow();
+ const borderExpandedStyles = removeBorderBottomIfExpanded(isExpanded);
+ const bgExpandedStyles = changeBgColorIfExpanded(isExpanded);
+ const budgetLineCreatorName = useGetUserFullNameFromId(creatorId);
+ const feeTotal = totalBudgetLineFeeAmount(amount, fee);
+ const budgetLineTotalPlusFees = totalBudgetLineAmountPlusFees(amount, feeTotal);
+ const displayCreatedDate = formatDateToMonthDayYear(creationDate);
+
+ const TableRowData = (
+ <>
+
+ {blId}
+ |
+
+ {agreementName}
+ |
+
+ {obligateDate}
+ |
+
+ {fiscalYear}
+ |
+
+
+ |
+
+ {percentOfCAN}%
+ |
+
+
+ |
+ >
+ );
+
+ const ExpandedData = (
+
+
+
+ - Created By
+ -
+ {budgetLineCreatorName}
+
+ -
+
+ {displayCreatedDate}
+
+
+
+ - Notes
+ -
+ {notes}
+
+
+
+
+ - Procurement Shop
+ -
+ {`${procShopCode}-Fee Rate: ${procShopFeePercentage * 100}%`}
+
+
+
+
+ - SubTotal
+ -
+
+
+
+
+ - Fees
+ -
+
+
+
+
+
+
+ |
+ );
+
+ return (
+
+ );
+};
+
+export default CANBudgetLineTableRow;
diff --git a/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.test.jsx b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.test.jsx
new file mode 100644
index 0000000000..bdc2d9e8af
--- /dev/null
+++ b/frontend/src/components/CANs/CANBudgetLineTable/CANBudgetLineTableRow.test.jsx
@@ -0,0 +1,84 @@
+import { render, screen } from "@testing-library/react";
+import CANBudgetLineTableRow from "./CANBudgetLineTableRow";
+import { formatDateNeeded } from "../../../helpers/utils";
+import { Provider } from "react-redux";
+import store from "../../../store";
+import { budgetLine } from "../../../tests/data";
+import userEvent from "@testing-library/user-event";
+
+const mockBudgetLine = {
+ ...budgetLine,
+ id: 1,
+ date_needed: "2023-10-01",
+ fiscal_year: 2023,
+ amount: 1000,
+ proc_shop_fee_percentage: 0.05,
+ status: "Pending",
+ in_review: true,
+ created_by: 1,
+ created_on: "2023-09-01"
+};
+
+describe("CANBudgetLineTableRow", () => {
+ test("renders table row data correctly", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("TBD")).toBeInTheDocument();
+ expect(screen.getByText(formatDateNeeded(mockBudgetLine.date_needed))).toBeInTheDocument();
+ expect(screen.getByText(mockBudgetLine.fiscal_year)).toBeInTheDocument();
+ expect(screen.getByText("$1,050.00")).toBeInTheDocument(); // amount + fee
+ expect(screen.getByText("3%")).toBeInTheDocument();
+ });
+
+ test("renders expanded data correctly", async () => {
+ render(
+
+
+
+ );
+
+ // Simulate expanding the row
+ await userEvent.click(screen.getByTestId("expand-row"));
+
+ expect(screen.getByText("Created By")).toBeInTheDocument();
+ expect(screen.getByText("comment one")).toBeInTheDocument();
+ expect(screen.getByText("Procurement Shop")).toBeInTheDocument();
+ expect(screen.getByText("$1,000.00")).toBeInTheDocument(); // amount
+ expect(screen.getByText("$50.00")).toBeInTheDocument(); // fee
+ });
+});
diff --git a/frontend/src/components/CANs/CANBudgetLineTable/index.js b/frontend/src/components/CANs/CANBudgetLineTable/index.js
new file mode 100644
index 0000000000..41a7b91331
--- /dev/null
+++ b/frontend/src/components/CANs/CANBudgetLineTable/index.js
@@ -0,0 +1 @@
+export { default } from "./CANBudgetLineTable";
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
index c1b93859fd..d82fbd06e9 100644
--- a/frontend/src/index.jsx
+++ b/frontend/src/index.jsx
@@ -247,7 +247,7 @@ const router = createBrowserRouter(
to="/cans"
className="text-primary"
>
- Cans
+ CANs
)
}}
diff --git a/frontend/src/pages/cans/detail/Can.jsx b/frontend/src/pages/cans/detail/Can.jsx
index af5d31525c..fc717e5a16 100644
--- a/frontend/src/pages/cans/detail/Can.jsx
+++ b/frontend/src/pages/cans/detail/Can.jsx
@@ -9,9 +9,11 @@ import CANFiscalYearSelect from "../list/CANFiscalYearSelect";
import CanDetail from "./CanDetail";
import CanFunding from "./CanFunding";
import CanSpending from "./CanSpending";
+import React from "react";
/**
- @typedef {import("../../../components/CANs/CANTypes").CAN} CAN
-*/
+ * @typedef {import("../../../components/CANs/CANTypes").CAN} CAN
+ * @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine
+ */
const Can = () => {
const urlPathParams = useParams();
@@ -21,6 +23,11 @@ const Can = () => {
const selectedFiscalYear = useSelector((state) => state.canDetail.selectedFiscalYear);
const fiscalYear = Number(selectedFiscalYear.value);
+ const filteredCANByFiscalYear = React.useMemo(() => {
+ if (!fiscalYear || !can) return {};
+ return can.funding_details?.fiscal_year === fiscalYear ? can : {};
+ }, [can, fiscalYear]);
+
if (isLoading) {
return Loading Can...
;
}
@@ -28,12 +35,18 @@ const Can = () => {
return Can not found
;
}
+ const { number, description, nick_name: nickname, portfolio } = can;
+
+ /** @type {{budget_line_items?: BudgetLine[]}} */
+ const { budget_line_items: budgetLines } = filteredCANByFiscalYear;
+ const { division_id: divisionId, team_leaders: teamLeaders, name: portfolioName } = portfolio;
+ const noData = "TBD";
const subTitle = `${can.nick_name} - ${can.active_period} ${can.active_period > 1 ? "Years" : "Year"}`;
return (
@@ -47,11 +60,20 @@ const Can = () => {
}
+ element={
+
+ }
/>
}
+ element={}
/>
{
- const canDivisionId = can.portfolio.division_id;
- const { data: division, isSuccess } = useGetDivisionQuery(canDivisionId);
+const CanDetail = ({ description, number, nickname, portfolioName, teamLeaders, divisionId }) => {
+ const { data: division, isSuccess } = useGetDivisionQuery(divisionId);
const divisionDirectorFullName = useGetUserFullNameFromId(isSuccess ? division.division_director_id : null);
return (
@@ -35,7 +39,7 @@ const CanDetail = ({ can }) => {
@@ -51,22 +55,22 @@ const CanDetail = ({ can }) => {
- - Team Leaders
- {can.portfolio?.team_leaders &&
- can.portfolio?.team_leaders.length > 0 &&
- can.portfolio.team_leaders.map((teamLeader) => (
+ - Team Leader
+ {teamLeaders &&
+ teamLeaders.length > 0 &&
+ teamLeaders.map((teamLeader) => (
- {
+import CANBudgetLineTable from "../../../components/CANs/CANBudgetLineTable";
+/**
+ @typedef {import("../../../components/CANs/CANTypes").CAN} CAN
+ @typedef {import("../../../components/BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine
+*/
+
+/**
+ * @typedef {Object} CanSpendingProps
+ * @property {BudgetLine[]} budgetLines
+ */
+
+/**
+ * @component - The CAN detail page.
+ * @param {CanSpendingProps} props
+ * @returns {JSX.Element} - The component JSX.
+ */
+const CanSpending = ({ budgetLines }) => {
return (
-
-
Can Spending
-
coming soon...
-
+
+ CAN Spending Summary
+ The summary below shows the CANs total budget and spending across all budget lines
+ {/* Note: Cards go here */}
+ CAN Budget Lines
+ This is a list of all budget lines allocating funding from this CAN for the selected fiscal year.
+
+
);
};