Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b7b51046b | |||
| 77b4bb310e | |||
| 47ba03ec63 | |||
| e331e1bb21 | |||
| 40f285f295 | |||
| 2d29346cbf | |||
| f70ea2fe71 | |||
| 29aaa72c60 | |||
| 12c7d30894 | |||
| 38cb1e207b | |||
| 4c879c614b | |||
| 8e012f760a | |||
| 8c09befd74 | |||
| 6af49cf1fd | |||
| 4ea604f9fb | |||
| 9d97bf7ea1 | |||
| eb015d712b | |||
| 1d04f4a42c | |||
| 11ee49a57a | |||
| 609731ff99 | |||
| d6cb32ed25 | |||
| 3668a7426f | |||
| cc82dcaafe | |||
| ea03bdecc5 | |||
| 51071b463b | |||
| ac0bd10212 | |||
| 1b73e355b7 | |||
| b661580c60 | |||
| dc4e07a692 | |||
| f2313b31f1 | |||
| f8810ee7e9 | |||
| 8ab4af8397 | |||
| 2382a559a5 | |||
| 5d889d824c | |||
| bed21122ce | |||
| ea8cf71101 | |||
| f1a26e132e | |||
| 1db6984de3 | |||
| 636ff237dd | |||
| 02963ab6d5 | |||
| d40f61292a | |||
| 7d94cb2ae4 | |||
| bb80476767 | |||
| 1635bc9c45 | |||
| f031b4eb89 | |||
| 93d4ab65ac | |||
| ce921a032a | |||
| 322f904b4b | |||
| 982eeac58c | |||
| 0ab9717556 | |||
| 24a85de8e1 | |||
| 65f0b979a4 | |||
| 947f34a0ad | |||
| 0f60fd7181 | |||
| 251062e42e | |||
| 05a328976e | |||
| 503cb08b2f | |||
| f71be20476 | |||
| e6397cecc1 | |||
| 80ce80ece4 | |||
| 9546fcd631 | |||
| b79aaf4327 | |||
| 76f9198f73 | |||
| d60029deae | |||
| d26f7d2511 | |||
| 33efe09b50 | |||
| e0aef9bf00 | |||
| 02d1a2e0b1 | |||
| 4e3154e929 | |||
| 32c0713256 | |||
| defe15bcec | |||
| 6f8e471f16 | |||
| dc35abfd85 | |||
| 04a8c5d52a | |||
| 2cb370053d | |||
| 1707f3802a | |||
| c87ba660ca | |||
| ef8a2dbc38 | |||
| 40d04a53c4 | |||
| d5ebb01ce3 | |||
| ec66631a33 | |||
| d66eb5dfc2 | |||
| 731b589ed8 | |||
| fe92d5fc36 | |||
| a335b400f1 | |||
| f63e507ddf | |||
| 991cc0567d | |||
| 52d58036a4 | |||
| 26bff85792 | |||
| 2ccf0d1100 | |||
| 3be33186bc | |||
| 1a7f950ae2 | |||
| 8924dc8ab1 | |||
| 2c2901b537 | |||
| 2cae7ea638 | |||
| 66e98a96cb | |||
| 0b0db1c543 | |||
| 80039f4876 | |||
| 326c26fddf | |||
| e7b2ead0e2 | |||
| a89657b0b8 | |||
| 4ee15e1b6e | |||
| ed40df6295 | |||
| 6d590c050d | |||
| 47f9a54f97 | |||
| 17b0d72fbf | |||
| 0269c2421d | |||
| 5b260e4915 | |||
| 5290410a17 | |||
| dc9041aaec | |||
| b0dc441d68 | |||
| b0fc3eb5af | |||
| d9980e866d | |||
| 52ae3404ee | |||
| eecb4f4f53 | |||
| 744345af81 | |||
| 7694d1b0fb | |||
| d8010d4c0c | |||
| a57b49c936 | |||
| a84ba41ea9 | |||
| dc200646f7 | |||
| e273e870ef | |||
| 6fc34aca00 | |||
| f97ac70678 | |||
| 6ceb681463 | |||
| 716ee6eba0 | |||
| f6b0f6b0cd | |||
| 737a652ff0 | |||
| 2995e5c9dc | |||
| 338c7a2e41 | |||
| ad27358deb | |||
| 495754816c | |||
| 96f2518af9 | |||
| 280bdeeb1b | |||
| 46cdeb0bab | |||
| d41f88f8bf | |||
| 815d6e97a8 | |||
| 4e35aefe41 | |||
| ca84915e43 | |||
| 31cc7e9e4d | |||
| 4ec107705e | |||
| 7740d2ac86 | |||
| 8c2aeacc85 | |||
| 8b8e8aec15 | |||
| 6ac3f660e9 | |||
| 7ee576a9c1 | |||
| f4c8699aaf | |||
| 4273ca6e5c | |||
| 4ba043b77e | |||
| 0169415ea2 | |||
| 86791dbaca | |||
| d5b58a3cbd | |||
| 3d8281d27e | |||
| b1a014c7bc | |||
| 505d0af2b3 | |||
| ece6bd1d78 | |||
| 8dc18b155a | |||
| aecd597687 | |||
| c99f106bae | |||
| d2fc7ae6fe | |||
| f37ec82d39 | |||
| 990ddb5cd3 | |||
| c6ebbb48bb |
+58
-37
@@ -2,39 +2,53 @@ name: Build
|
|||||||
run-name: Running Lint Check and Licence checker on Pull Request
|
run-name: Running Lint Check and Licence checker on Pull Request
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Build-and-ng-test:
|
Build-and-ng-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.5.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Install Google Chrome
|
- name: Install Google Chrome
|
||||||
run: |
|
run: |
|
||||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
|
|
||||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list
|
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y google-chrome-stable xvfb
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
apt install -y ./google-chrome*.deb
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: echo "$NPMRC" > client/.npmrc
|
run: echo "$NPMRC" >> client/.npmrc
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Lint check
|
|
||||||
run: npm run lint:check
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo "${{ secrets.SHEET_PWD }}" | \
|
||||||
|
gpg --batch --yes --passphrase-fd 0 \
|
||||||
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
|
- name: Check audit
|
||||||
|
# Audit should fail and stop the CI if critical vulnerability found
|
||||||
|
run: |
|
||||||
|
npm audit --audit-level=critical --omit=dev
|
||||||
|
cd ./sas
|
||||||
|
npm audit --audit-level=critical --omit=dev
|
||||||
|
cd ../client
|
||||||
|
npm audit --audit-level=critical --omit=dev
|
||||||
|
|
||||||
|
- name: Lint check
|
||||||
|
run: npm run lint:check
|
||||||
|
|
||||||
- name: Licence checker
|
- name: Licence checker
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
@@ -52,26 +66,33 @@ jobs:
|
|||||||
|
|
||||||
Build-and-test-development:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: Build-production-and-ng-test
|
needs: Build-and-ng-test
|
||||||
|
env:
|
||||||
|
CHROME_BIN: /usr/bin/google-chrome
|
||||||
|
# Pin OS locale
|
||||||
|
LANG: en_GB.UTF-8
|
||||||
|
LC_ALL: en_GB.UTF-8
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.5.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
touch client/.npmrc
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
|
|
||||||
- run: apt-get update
|
- name: Install system dependencies
|
||||||
- run: wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
run: |
|
||||||
- run: apt install -y ./google-chrome*.deb;
|
apt-get update
|
||||||
- run: export CHROME_BIN=/usr/bin/google-chrome
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
- run: apt-get update -y
|
apt install -y ./google-chrome*.deb
|
||||||
- run: apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
|
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip locales
|
||||||
- run: apt -y install jq
|
# Generate the en_GB.UTF-8 locale referenced by LANG/LC_ALL
|
||||||
|
locale-gen en_GB.UTF-8
|
||||||
|
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
||||||
|
|
||||||
- name: Write cypress credentials
|
- name: Write cypress credentials
|
||||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||||
@@ -86,17 +107,18 @@ jobs:
|
|||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
# Install pm2 and prepare SASJS server
|
- name: Setup and start SASjs server
|
||||||
- run: npm i -g pm2
|
run: |
|
||||||
- run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
npm i -g pm2
|
||||||
- run: unzip linux.zip
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
- run: touch .env
|
unzip linux.zip
|
||||||
- run: echo RUN_TIMES=js >> .env
|
touch .env
|
||||||
- run: echo NODE_PATH=node >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
- run: echo CORS=enable >> .env
|
echo NODE_PATH=node >> .env
|
||||||
- run: echo WHITELIST=http://localhost:4200 >> .env
|
echo CORS=enable >> .env
|
||||||
- run: cat .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
- run: pm2 start api-linux --wait-ready
|
cat .env
|
||||||
|
pm2 start api-linux --wait-ready
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
run: |
|
run: |
|
||||||
@@ -106,12 +128,8 @@ jobs:
|
|||||||
sasjs cbd -t server-ci
|
sasjs cbd -t server-ci
|
||||||
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
||||||
|
|
||||||
- name: Install ZIP
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install zip
|
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- name: Prepare and run frontend and cypress
|
||||||
|
timeout-minutes: 35
|
||||||
run: |
|
run: |
|
||||||
cd ./client
|
cd ./client
|
||||||
mv ./cypress.env.example.json ./cypress.env.json
|
mv ./cypress.env.example.json ./cypress.env.json
|
||||||
@@ -126,11 +144,14 @@ jobs:
|
|||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
|
||||||
|
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
|
||||||
|
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress videos
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p ./client/cypress/videos
|
||||||
zip -r cypress-videos ./client/cypress/videos
|
zip -r cypress-videos ./client/cypress/videos
|
||||||
|
|
||||||
- name: Add cypress videos artifacts
|
- name: Add cypress videos artifacts
|
||||||
|
|||||||
@@ -2,38 +2,31 @@ name: Lighthouse Checks
|
|||||||
run-name: Running Lighthouse Performance and Accessibility Checks on Pull Request
|
run-name: Running Lighthouse Performance and Accessibility Checks on Pull Request
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '24.15.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lighthouse:
|
lighthouse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [24.5.0]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ env.NODE_VERSION }}
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Install Google Chrome
|
- name: Install Google Chrome
|
||||||
run: |
|
run: |
|
||||||
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
|
|
||||||
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list
|
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y google-chrome-stable xvfb
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
|
apt install -y ./google-chrome*.deb
|
||||||
|
|
||||||
- name: Install pm2 for process management
|
- name: Install global packages
|
||||||
run: npm i -g pm2
|
run: npm i -g pm2 @sasjs/cli wait-on
|
||||||
|
|
||||||
- name: Install @sasjs/cli
|
- name: Setup and start SASjs server
|
||||||
run: npm i -g @sasjs/cli
|
|
||||||
|
|
||||||
- name: Install wait-on globally
|
|
||||||
run: npm install -g wait-on
|
|
||||||
|
|
||||||
- name: Create .env file for sasjs/server
|
|
||||||
run: |
|
run: |
|
||||||
touch .env
|
touch .env
|
||||||
echo RUN_TIMES=js >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
@@ -41,15 +34,9 @@ jobs:
|
|||||||
echo CORS=enable >> .env
|
echo CORS=enable >> .env
|
||||||
echo WHITELIST=http://localhost:4200 >> .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
cat .env
|
cat .env
|
||||||
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
- name: Download sasjs/server package from github using curl
|
unzip linux.zip
|
||||||
run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
pm2 start api-linux --wait-ready
|
||||||
|
|
||||||
- name: Unzip downloaded package
|
|
||||||
run: unzip linux.zip
|
|
||||||
|
|
||||||
- name: Run sasjs server
|
|
||||||
run: pm2 start api-linux --wait-ready
|
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: echo "$NPMRC" > client/.npmrc
|
run: echo "$NPMRC" > client/.npmrc
|
||||||
@@ -61,14 +48,17 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
# Decrypt and Install sheet
|
# Decrypt and Install sheet
|
||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo "${{ secrets.SHEET_PWD }}" | \
|
||||||
|
gpg --batch --yes --passphrase-fd 0 \
|
||||||
|
--output ./libraries/sheet-crypto.tgz \
|
||||||
|
--decrypt ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
npm install -g replace-in-files-cli
|
npm install -g replace-in-files-cli
|
||||||
|
|
||||||
- name: Update appLoc in index.html
|
- name: Update appLoc in index.html
|
||||||
run: |
|
run: |
|
||||||
cd client
|
cd client
|
||||||
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/proj/sasjs/genesis-mocks"' ./src/index.html
|
replace-in-files --regex='appLoc=".*?"' --replacement='appLoc="/Public/app/devtest"' ./src/index.html
|
||||||
|
|
||||||
- name: Build Frontend
|
- name: Build Frontend
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '24.5.0'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Build-production-and-ng-test:
|
Build-production-and-ng-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CHROME_BIN: /usr/bin/google-chrome
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.5.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
@@ -24,8 +29,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
apt install -y ./google-chrome*.deb;
|
apt install -y ./google-chrome*.deb
|
||||||
export CHROME_BIN=/usr/bin/google-chrome
|
|
||||||
|
|
||||||
- name: Write cypress credentials
|
- name: Write cypress credentials
|
||||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||||
@@ -43,9 +47,9 @@ jobs:
|
|||||||
- name: Check audit
|
- name: Check audit
|
||||||
# Audit should fail and stop the CI if critical vulnerability found
|
# Audit should fail and stop the CI if critical vulnerability found
|
||||||
run: |
|
run: |
|
||||||
npm audit --audit-level=critical --omit=dev
|
npm audit --omit=dev
|
||||||
cd ./sas
|
cd ./sas
|
||||||
npm audit --audit-level=critical --omit=dev
|
npm audit --omit=dev
|
||||||
cd ../client
|
cd ../client
|
||||||
npm audit --audit-level=critical --omit=dev
|
npm audit --audit-level=critical --omit=dev
|
||||||
|
|
||||||
@@ -63,25 +67,32 @@ jobs:
|
|||||||
Build-and-test-development:
|
Build-and-test-development:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: Build-production-and-ng-test
|
needs: Build-production-and-ng-test
|
||||||
|
env:
|
||||||
|
CHROME_BIN: /usr/bin/google-chrome
|
||||||
|
# Pin OS locale
|
||||||
|
LANG: en_GB.UTF-8
|
||||||
|
LC_ALL: en_GB.UTF-8
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.5.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
touch client/.npmrc
|
touch client/.npmrc
|
||||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||||
|
|
||||||
- run: apt-get update
|
- name: Install system dependencies
|
||||||
- run: wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
run: |
|
||||||
- run: apt install -y ./google-chrome*.deb;
|
apt-get update
|
||||||
- run: export CHROME_BIN=/usr/bin/google-chrome
|
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||||
- run: apt-get update -y
|
apt install -y ./google-chrome*.deb
|
||||||
- run: apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
|
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip locales
|
||||||
- run: apt -y install jq
|
# Generate the en_GB.UTF-8 locale referenced by LANG/LC_ALL
|
||||||
|
locale-gen en_GB.UTF-8
|
||||||
|
update-locale LANG=en_GB.UTF-8 LC_ALL=en_GB.UTF-8
|
||||||
|
|
||||||
- name: Write cypress credentials
|
- name: Write cypress credentials
|
||||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||||
@@ -96,17 +107,18 @@ jobs:
|
|||||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
# Install pm2 and prepare SASJS server
|
- name: Setup and start SASjs server
|
||||||
- run: npm i -g pm2
|
run: |
|
||||||
- run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
npm i -g pm2
|
||||||
- run: unzip linux.zip
|
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||||
- run: touch .env
|
unzip linux.zip
|
||||||
- run: echo RUN_TIMES=js >> .env
|
touch .env
|
||||||
- run: echo NODE_PATH=node >> .env
|
echo RUN_TIMES=js >> .env
|
||||||
- run: echo CORS=enable >> .env
|
echo NODE_PATH=node >> .env
|
||||||
- run: echo WHITELIST=http://localhost:4200 >> .env
|
echo CORS=enable >> .env
|
||||||
- run: cat .env
|
echo WHITELIST=http://localhost:4200 >> .env
|
||||||
- run: pm2 start api-linux --wait-ready
|
cat .env
|
||||||
|
pm2 start api-linux --wait-ready
|
||||||
|
|
||||||
- name: Deploy mocked services
|
- name: Deploy mocked services
|
||||||
run: |
|
run: |
|
||||||
@@ -116,11 +128,6 @@ jobs:
|
|||||||
sasjs cbd -t server-ci
|
sasjs cbd -t server-ci
|
||||||
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
# sasjs request services/admin/makedata -t server-ci -d ./deploy/makeData4GL.json -c ./deploy/requestConfig.json -o ./output.json
|
||||||
|
|
||||||
- name: Install ZIP
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install zip
|
|
||||||
|
|
||||||
- name: Prepare and run frontend and cypress
|
- name: Prepare and run frontend and cypress
|
||||||
run: |
|
run: |
|
||||||
cd ./client
|
cd ./client
|
||||||
@@ -136,11 +143,12 @@ jobs:
|
|||||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||||
cat ./cypress.config.ts
|
cat ./cypress.config.ts
|
||||||
# Start frontend and run cypress
|
# Start frontend and run cypress
|
||||||
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/licensing.cy.ts"
|
||||||
|
|
||||||
- name: Zip Cypress videos
|
- name: Zip Cypress videos
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
|
mkdir -p ./client/cypress/videos
|
||||||
zip -r cypress-videos ./client/cypress/videos
|
zip -r cypress-videos ./client/cypress/videos
|
||||||
|
|
||||||
- name: Add cypress videos artifacts
|
- name: Add cypress videos artifacts
|
||||||
@@ -155,10 +163,10 @@ jobs:
|
|||||||
needs: [Build-production-and-ng-test, Build-and-test-development]
|
needs: [Build-production-and-ng-test, Build-and-test-development]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.5.0
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Write .npmrc file
|
- name: Write .npmrc file
|
||||||
run: |
|
run: |
|
||||||
@@ -168,17 +176,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NPMRC: ${{ secrets.NPMRC}}
|
NPMRC: ${{ secrets.NPMRC}}
|
||||||
|
|
||||||
- name: Install packages
|
- name: Install system packages
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install zip -y
|
apt-get install -y zip jq doxygen
|
||||||
# sasjs cli is used to compile & build the SAS services
|
|
||||||
npm i -g @sasjs/cli
|
npm i -g @sasjs/cli
|
||||||
# jq is used to parse the release JSON
|
|
||||||
apt-get install jq -y
|
|
||||||
# doxygen is used for the SASJS docs
|
|
||||||
apt-get update
|
|
||||||
apt-get install doxygen -y
|
|
||||||
|
|
||||||
- name: Frontend Preliminary Build
|
- name: Frontend Preliminary Build
|
||||||
description: We want to prevent creating empty release if frontend fails
|
description: We want to prevent creating empty release if frontend fails
|
||||||
@@ -228,6 +230,8 @@ jobs:
|
|||||||
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
|
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
|
||||||
sasjs c -t server
|
sasjs c -t server
|
||||||
rm -rf sasjsbuild/tests
|
rm -rf sasjsbuild/tests
|
||||||
|
server_apploc="/Public/app/dc"
|
||||||
|
sed -i "s|apploc=\"[^\"]*\"|apploc=\"${server_apploc}\"|g" sasjsbuild/services/web/index.html
|
||||||
sasjs b -t server
|
sasjs b -t server
|
||||||
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
|
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ client/documentation
|
|||||||
client/**/sheet-crypto.tgz
|
client/**/sheet-crypto.tgz
|
||||||
client/.nx
|
client/.nx
|
||||||
client/libraries/sheet-crypto.tgz
|
client/libraries/sheet-crypto.tgz
|
||||||
|
client/lighthouse-reports
|
||||||
cypress.env.json
|
cypress.env.json
|
||||||
sasjsbuild
|
sasjsbuild
|
||||||
sasjsresults
|
sasjsresults
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
legacy-peer-deps=true
|
legacy-peer-deps=true
|
||||||
|
ignore-scripts=true
|
||||||
|
save-exact=true
|
||||||
|
fund=false
|
||||||
+165
@@ -1,3 +1,168 @@
|
|||||||
|
## [7.8.2](https://git.datacontroller.io/dc/dc/compare/v7.8.1...v7.8.2) (2026-05-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bumping ws package ([2382a55](https://git.datacontroller.io/dc/dc/commit/2382a559a5ac32b0f815776a90207650d5809ba6))
|
||||||
|
* enabling version restore for non admin users ([5d889d8](https://git.datacontroller.io/dc/dc/commit/5d889d824cc2f8e4ea089cbb578453125dc4ba6c))
|
||||||
|
|
||||||
|
## [7.8.1](https://git.datacontroller.io/dc/dc/compare/v7.8.0...v7.8.1) (2026-05-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **sasjs:** enable runAsTask ([f1a26e1](https://git.datacontroller.io/dc/dc/commit/f1a26e132eba7fa2ac64754940b52ea46c6619b3))
|
||||||
|
|
||||||
|
# [7.8.0](https://git.datacontroller.io/dc/dc/compare/v7.7.3...v7.8.0) (2026-05-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* enabling DSN=*ALL* in MPE_SECURITY ([7d94cb2](https://git.datacontroller.io/dc/dc/commit/7d94cb2ae4a3f6c1fa1011ae0fced7083a2f2793))
|
||||||
|
* providing default values for RULE_ACTIVE on MPE_VALIDATIONS ([f031b4e](https://git.datacontroller.io/dc/dc/commit/f031b4eb8925397e60dcc739a721cfbbb6da8dff))
|
||||||
|
* switch away from api usage for CASLIB metadata ([ce921a0](https://git.datacontroller.io/dc/dc/commit/ce921a032a8970b8078a463a41da884e1fa71bc3))
|
||||||
|
* use correct debug param for runAsTask ([bb80476](https://git.datacontroller.io/dc/dc/commit/bb8047676749814d3b86eea666726dbe4bf5f270))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add runAsTask config attribute parser ([1635bc9](https://git.datacontroller.io/dc/dc/commit/1635bc9c451bc221f386241007f594096f114b4f))
|
||||||
|
* enabling *ALL* option by default in MPE_SECURITY (DSN col) ([93d4ab6](https://git.datacontroller.io/dc/dc/commit/93d4ab65acce7b5b35e448146f9893964ad2cca3))
|
||||||
|
|
||||||
|
## [7.7.3](https://git.datacontroller.io/dc/dc/compare/v7.7.2...v7.7.3) (2026-05-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* move cas session assign to settings.sas and abort when lib is unassigned ([65f0b97](https://git.datacontroller.io/dc/dc/commit/65f0b979a401277b3e070d409659ae3fae2ff8c0))
|
||||||
|
|
||||||
|
## [7.7.2](https://git.datacontroller.io/dc/dc/compare/v7.7.1...v7.7.2) (2026-05-07)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** bundle Metropolis font locally to satisfy CSP ([9546fcd](https://git.datacontroller.io/dc/dc/commit/9546fcd6312f3e81f746ef6e32ef398810ed434a))
|
||||||
|
* **client:** clear angular build cache on font strip to avoid stale dist ([503cb08](https://git.datacontroller.io/dc/dc/commit/503cb08b2fa40397434189f9c20eff3358eb7010))
|
||||||
|
* **client:** postinstall removal of Metropolis [@font-face](https://git.datacontroller.io/font-face) from @clr/ui ([e6397ce](https://git.datacontroller.io/dc/dc/commit/e6397cecc13afe2a9238bdfb2b4b9b81f38d055c))
|
||||||
|
* **client:** serve text-security-disc font locally ([80ce80e](https://git.datacontroller.io/dc/dc/commit/80ce80ece40012e59c7cd0340b4aa9a9aca46443))
|
||||||
|
* **editor:** preserve numeric type for SAS num cols with static SOFTSELECT/HARDSELECT ([05a3289](https://git.datacontroller.io/dc/dc/commit/05a328976ea3d1d6ef7559850369aa580f0d067f))
|
||||||
|
|
||||||
|
## [7.7.1](https://git.datacontroller.io/dc/dc/compare/v7.7.0...v7.7.1) (2026-05-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **client:** bump adapter ([d26f7d2](https://git.datacontroller.io/dc/dc/commit/d26f7d2511008634124c7d6fde115abb43db9c43))
|
||||||
|
* **sas:** bump cli ([d60029d](https://git.datacontroller.io/dc/dc/commit/d60029deae0ec21f3b8570461e2a4ca041d58f72))
|
||||||
|
|
||||||
|
# [7.7.0](https://git.datacontroller.io/dc/dc/compare/v7.6.0...v7.7.0) (2026-05-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bump adapter to 4.16.6 ([1707f38](https://git.datacontroller.io/dc/dc/commit/1707f3802a97de8c659f1a88c92fc917e8a30615))
|
||||||
|
* remove data:image/svg+xml CSP violation, use class instead changing style directly ([d66eb5d](https://git.datacontroller.io/dc/dc/commit/d66eb5dfc2dbb01f1e6c0c7d15fc2ad2a39dd829))
|
||||||
|
* remove WORK, SASUSER and CASUSER as library options. [#224](https://git.datacontroller.io/dc/dc/issues/224) ([ec66631](https://git.datacontroller.io/dc/dc/commit/ec66631a33aabb8ab2f92fe22c15440127085782))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* auto-save CAS tables [#224](https://git.datacontroller.io/dc/dc/issues/224) ([40d04a5](https://git.datacontroller.io/dc/dc/commit/40d04a53c4c00183116bdbd08397e0f2ffb1f578))
|
||||||
|
* autoload CAS tables. [#224](https://git.datacontroller.io/dc/dc/issues/224) ([d5ebb01](https://git.datacontroller.io/dc/dc/commit/d5ebb01ce381f5f4ec06de041f3ab9e632c02e43))
|
||||||
|
|
||||||
|
# [7.6.0](https://git.datacontroller.io/dc/dc/compare/v7.5.0...v7.6.0) (2026-04-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add label and tooltip for libref download, sanitise input ([52d5803](https://git.datacontroller.io/dc/dc/commit/52d58036a40e25847e900f9b04a77dbcc409c12b))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* configurable email alerts. Closes [#217](https://git.datacontroller.io/dc/dc/issues/217) ([2ccf0d1](https://git.datacontroller.io/dc/dc/commit/2ccf0d11000129629a0665421135b7530af9892f))
|
||||||
|
|
||||||
|
# [7.5.0](https://git.datacontroller.io/dc/dc/compare/v7.4.1...v7.5.0) (2026-04-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add workflow audits, update deps ([66e98a9](https://git.datacontroller.io/dc/dc/commit/66e98a96cbd092e762b94a04660f8e17ca003ceb))
|
||||||
|
* allow CSV uploads with licence row limit ([5b260e4](https://git.datacontroller.io/dc/dc/commit/5b260e49153dd85bc0023ad94d8a5f57b8ffa6dc)), closes [#213](https://git.datacontroller.io/dc/dc/issues/213)
|
||||||
|
* bumping cli and pinning versions in .npmrc ([80039f4](https://git.datacontroller.io/dc/dc/commit/80039f4876c8e09dc477678e1eff58329094c9e9))
|
||||||
|
* guard CSV upload with fileUpload licence flag ([ed40df6](https://git.datacontroller.io/dc/dc/commit/ed40df62953c3055770b5cbf50738f4a48b943cd))
|
||||||
|
* parse embed param from window.location.hash for hash router compatibility ([0269c24](https://git.datacontroller.io/dc/dc/commit/0269c2421db245f7f5405678605cb4d4587e2a67))
|
||||||
|
* quote CSV char values. Closes [#215](https://git.datacontroller.io/dc/dc/issues/215) ([d9980e8](https://git.datacontroller.io/dc/dc/commit/d9980e866d1a2fe7a731ff279d73accd35003e67))
|
||||||
|
* resolve outer promise in parseCsvFile for non-WLATIN1 path ([4ee15e1](https://git.datacontroller.io/dc/dc/commit/4ee15e1b6e83f27f279fc345e6998452a8f64d7e))
|
||||||
|
* use XLSX for CSV row truncation to handle new lines in values ([6d590c0](https://git.datacontroller.io/dc/dc/commit/6d590c050dcd593a73464fae5604f774f016b10d))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add embed URL parameter to hide header and back button ([b0dc441](https://git.datacontroller.io/dc/dc/commit/b0dc441d681369e06eee58288dbdbb236f930bdc)), closes [#214](https://git.datacontroller.io/dc/dc/issues/214)
|
||||||
|
* add target libref input to config download ([a89657b](https://git.datacontroller.io/dc/dc/commit/a89657b0b81b9c531f64c0dda2714b4eb16c4bc9)), closes [#212](https://git.datacontroller.io/dc/dc/issues/212)
|
||||||
|
* export config service to allow dclib swapping. Closes [#212](https://git.datacontroller.io/dc/dc/issues/212) ([326c26f](https://git.datacontroller.io/dc/dc/commit/326c26fddfa88a0dc4ca79d3bd0c77c4d807f37c))
|
||||||
|
|
||||||
|
## [7.4.1](https://git.datacontroller.io/dc/dc/compare/v7.4.0...v7.4.1) (2026-03-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* support for SASIOSNF engine (SNOW alias) plus meta assignment ([7694d1b](https://git.datacontroller.io/dc/dc/commit/7694d1b0fb2bd0407c8598147fbae87a00d889a8))
|
||||||
|
|
||||||
|
# [7.4.0](https://git.datacontroller.io/dc/dc/compare/v7.3.0...v7.4.0) (2026-02-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* cli bump for mf_getscheme support ([a84ba41](https://git.datacontroller.io/dc/dc/commit/a84ba41ea9f0c97ae24f0a572b8cf5ec200f2132))
|
||||||
|
* missing upcase on SNOW section, plus local sasjs target ([dc20064](https://git.datacontroller.io/dc/dc/commit/dc200646f7df2fd1910841f392c314532aae7581))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* SAS code changes for snowflake support ([e273e87](https://git.datacontroller.io/dc/dc/commit/e273e870efbf7875db869b760f2c7b1f39d571ae))
|
||||||
|
|
||||||
|
# [7.3.0](https://git.datacontroller.io/dc/dc/compare/v7.2.8...v7.3.0) (2026-02-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bump xlsx, add crypto-shim ([8dc18b1](https://git.datacontroller.io/dc/dc/commit/8dc18b155abfc20fd0b043e0d70bbbc17e6b6811))
|
||||||
|
* correctly applying deletes on viya, also ([46cdeb0](https://git.datacontroller.io/dc/dc/commit/46cdeb0babee6870553a41877cbe85204e7099c4))
|
||||||
|
* crypto module requirement for sheetjs/crypto package ([505d0af](https://git.datacontroller.io/dc/dc/commit/505d0af2b3b3c1c79c65045dcaffc263e0f8e796))
|
||||||
|
* disable parsing excel in web worker beacuse it breaks in the stream apps ([280bdee](https://git.datacontroller.io/dc/dc/commit/280bdeeb1b82f00689f46c68a3cde3f2d24bc18f))
|
||||||
|
* Display all contexts when installing DC on Viya ([d41f88f](https://git.datacontroller.io/dc/dc/commit/d41f88f8bf5bb2c725ee3edba085e8961ab8c727))
|
||||||
|
* **edit:** use cellValidation keys and hotDataSchema to fill in defaults on add row ([4957548](https://git.datacontroller.io/dc/dc/commit/495754816c0e757b8f8b1c0ad51246dc7b65d957))
|
||||||
|
* enabling closeouts for UPDATE in CAS tables ([8b8e8ae](https://git.datacontroller.io/dc/dc/commit/8b8e8aec159ff2f50cfa4683bcd7a25aabb75bf8))
|
||||||
|
* enabling rollback when the table has formatted values ([815d6e9](https://git.datacontroller.io/dc/dc/commit/815d6e97a8e304d79d48cc949ba126e02a318dc1))
|
||||||
|
* improvements to validations ([6ceb681](https://git.datacontroller.io/dc/dc/commit/6ceb6814633691b6d4ac2cb898cfb75e9d609102))
|
||||||
|
* remove IE checks and conditions ([ece6bd1](https://git.datacontroller.io/dc/dc/commit/ece6bd1d787d722531334fc4f1396a94cf6d92ec))
|
||||||
|
* updates to demodata to enable auto CAS promote ([7740d2a](https://git.datacontroller.io/dc/dc/commit/7740d2ac8694295b33b40a30603d8239818896f5))
|
||||||
|
* upgrade angular core and compiler ([aecd597](https://git.datacontroller.io/dc/dc/commit/aecd5976875a7c01189248c5f5aa3478b28c1ab2))
|
||||||
|
* using fcopy instead of binary copy for file upload, for Viya 2026 compatibility ([716ee6e](https://git.datacontroller.io/dc/dc/commit/716ee6eba0a28f4f0a7a96b0719caf15da9b6e78))
|
||||||
|
* **viewer:** search causing blank Handsontable ([338c7a2](https://git.datacontroller.io/dc/dc/commit/338c7a2e418c47e34331bd04718cd816f978837c)), closes [#206](https://git.datacontroller.io/dc/dc/issues/206)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* adding demo data job ([8c2aeac](https://git.datacontroller.io/dc/dc/commit/8c2aeacc85da5c106c356709cefcb412ed0a71db))
|
||||||
|
* **dq rules:** notnull validation when invalid cell, will auto populate a default value ([96f2518](https://git.datacontroller.io/dc/dc/commit/96f2518af9e547956be5862a1322d9ab8e07369b))
|
||||||
|
|
||||||
|
## [7.2.8](https://git.datacontroller.io/dc/dc/compare/v7.2.7...v7.2.8) (2026-02-06)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* bump adapter version ([f4c8699](https://git.datacontroller.io/dc/dc/commit/f4c8699aaf0b1e01b447296978a4f6dedc8903f9))
|
||||||
|
|
||||||
|
## [7.2.7](https://git.datacontroller.io/dc/dc/compare/v7.2.6...v7.2.7) (2026-02-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* dclib not found error in getchangeinfo job ([86791db](https://git.datacontroller.io/dc/dc/commit/86791dbaca39034a19bf8f34efbddf898c57f2f7))
|
||||||
|
|
||||||
## [7.2.6](https://git.datacontroller.io/dc/dc/compare/v7.2.5...v7.2.6) (2026-01-05)
|
## [7.2.6](https://git.datacontroller.io/dc/dc/compare/v7.2.5...v7.2.6) (2026-01-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ This project includes automated Lighthouse performance and accessibility checks
|
|||||||
The Lighthouse CI workflow:
|
The Lighthouse CI workflow:
|
||||||
1. Sets up the development environment with SASjs server and mocked services
|
1. Sets up the development environment with SASjs server and mocked services
|
||||||
2. Builds and serves the Angular frontend
|
2. Builds and serves the Angular frontend
|
||||||
3. Runs Lighthouse CI against key application pages
|
3. Installs Chrome and runs `lhci autorun` (Lighthouse CI) against key pages
|
||||||
4. Uploads results as artifacts for review
|
4. Uploads results as artifacts for review
|
||||||
|
|
||||||
To run Lighthouse checks locally:
|
To run Lighthouse checks locally:
|
||||||
@@ -61,4 +61,4 @@ npm install
|
|||||||
npm run lighthouse
|
npm run lighthouse
|
||||||
```
|
```
|
||||||
|
|
||||||
Configuration is in `client/lighthouserc.js`.
|
Configuration is in `client/lighthouserc.js` (URL list, `desktop` preset, Chrome flags, assertions).
|
||||||
|
|||||||
+5
-1
@@ -25,6 +25,7 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"handsontable",
|
"handsontable",
|
||||||
|
"exceljs",
|
||||||
"core-js",
|
"core-js",
|
||||||
"pikaday",
|
"pikaday",
|
||||||
"querystring",
|
"querystring",
|
||||||
@@ -41,6 +42,8 @@
|
|||||||
"zone.js",
|
"zone.js",
|
||||||
"text-encoding",
|
"text-encoding",
|
||||||
"crypto-js/md5",
|
"crypto-js/md5",
|
||||||
|
"crypto-js/sha1",
|
||||||
|
"crypto-js/sha512",
|
||||||
"buffer",
|
"buffer",
|
||||||
"numbro",
|
"numbro",
|
||||||
"@clr/icons",
|
"@clr/icons",
|
||||||
@@ -60,7 +63,8 @@
|
|||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "src/images",
|
"input": "src/images",
|
||||||
"output": "images"
|
"output": "images",
|
||||||
|
"ignore": ["spinner.svg", "caret.svg"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": ["src/styles.scss"],
|
"styles": ["src/styles.scss"],
|
||||||
|
|||||||
+26
-18
@@ -1,13 +1,13 @@
|
|||||||
import { defineConfig } from "cypress";
|
import { defineConfig } from 'cypress'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
reporter: "mochawesome",
|
reporter: 'mochawesome',
|
||||||
|
|
||||||
reporterOptions: {
|
reporterOptions: {
|
||||||
reportDir: "cypress/results",
|
reportDir: 'cypress/results',
|
||||||
overwrite: false,
|
overwrite: false,
|
||||||
html: true,
|
html: true,
|
||||||
json: false,
|
json: false
|
||||||
},
|
},
|
||||||
viewportHeight: 900,
|
viewportHeight: 900,
|
||||||
viewportWidth: 1600,
|
viewportWidth: 1600,
|
||||||
@@ -16,24 +16,32 @@ export default defineConfig({
|
|||||||
defaultCommandTimeout: 30000,
|
defaultCommandTimeout: 30000,
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
hosturl: "http://localhost:4200",
|
hosturl: 'http://localhost:4200',
|
||||||
appLocation: "",
|
appLocation: '',
|
||||||
site_id_SAS9: "70221618",
|
site_id_SAS9: '70221618',
|
||||||
site_id_SASVIYA: "70253615",
|
site_id_SASVIYA: '70253615',
|
||||||
site_id_SASJS: "123",
|
site_id_SASJS: '123',
|
||||||
serverType: "SASJS",
|
serverType: 'SASJS',
|
||||||
libraryToOpenIncludes_SASVIYA: "viya",
|
libraryToOpenIncludes_SASVIYA: 'viya',
|
||||||
libraryToOpenIncludes_SAS9: "dc",
|
libraryToOpenIncludes_SAS9: 'dc',
|
||||||
libraryToOpenIncludes_SASJS: "dc",
|
libraryToOpenIncludes_SASJS: 'dc',
|
||||||
debug: false,
|
debug: false,
|
||||||
screenshotOnRunFailure: false,
|
screenshotOnRunFailure: false,
|
||||||
longerCommandTimeout: 50000,
|
longerCommandTimeout: 50000,
|
||||||
testLicenceUserLimits: false,
|
testLicenceUserLimits: false
|
||||||
},
|
},
|
||||||
|
|
||||||
e2e: {
|
e2e: {
|
||||||
|
video: true,
|
||||||
setupNodeEvents(on, config) {
|
setupNodeEvents(on, config) {
|
||||||
// implement node event listeners here
|
// Pin the browser locale so locale-formatted cells (intl-date/time/datetime)
|
||||||
},
|
// render deterministically regardless of the runner's system locale.
|
||||||
},
|
on('before:browser:launch', (browser, launchOptions) => {
|
||||||
});
|
if (browser.family === 'chromium' && browser.name !== 'electron') {
|
||||||
|
launchOptions.args.push('--lang=en-GB')
|
||||||
|
}
|
||||||
|
return launchOptions
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const username = Cypress.env('username')
|
||||||
|
const password = Cypress.env('password')
|
||||||
|
const hostUrl = Cypress.env('hosturl')
|
||||||
|
const appLocation = Cypress.env('appLocation')
|
||||||
|
const longerCommandTimeout = Cypress.env('longerCommandTimeout')
|
||||||
|
const serverType = Cypress.env('serverType')
|
||||||
|
const libraryToOpenIncludes = Cypress.env(`libraryToOpenIncludes_${serverType}`)
|
||||||
|
const fixturePath = 'csvs/'
|
||||||
|
|
||||||
|
context('csv file upload restriction (free tier): ', function () {
|
||||||
|
this.beforeEach(() => {
|
||||||
|
cy.visit(hostUrl + appLocation)
|
||||||
|
|
||||||
|
cy.get('body').then(($body) => {
|
||||||
|
const usernameInput = $body.find('input.username')[0]
|
||||||
|
|
||||||
|
if (usernameInput && !Cypress.dom.isHidden(usernameInput)) {
|
||||||
|
cy.get('input.username').type(username)
|
||||||
|
cy.get('input.password').type(password)
|
||||||
|
cy.get('.login-group button').click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout }).should(
|
||||||
|
'not.exist'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip licensing page if presented - continue with free tier
|
||||||
|
cy.url().then((url) => {
|
||||||
|
if (url.includes('licensing')) {
|
||||||
|
cy.get('button').contains('Continue with free tier').click()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
visitPage('home')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 | File upload is restricted on free tier', () => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
// Click upload button - should show feature locked modal
|
||||||
|
cy.get('.buttonBar button:last-child').should('exist').click()
|
||||||
|
|
||||||
|
cy.get('.modal-title').should('contain', 'Locked Feature (File Upload)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const openTableFromTree = (libNameIncludes: string, tablename: string) => {
|
||||||
|
cy.get('.app-loading', { timeout: longerCommandTimeout })
|
||||||
|
.should('not.exist')
|
||||||
|
.then(() => {
|
||||||
|
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||||
|
timeout: longerCommandTimeout
|
||||||
|
}).then((treeNodes: any) => {
|
||||||
|
let targetLib
|
||||||
|
|
||||||
|
for (let node of treeNodes) {
|
||||||
|
if (node.innerText.toLowerCase().includes(libNameIncludes)) {
|
||||||
|
targetLib = node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get(targetLib).within(() => {
|
||||||
|
cy.get('.clr-tree-node-content-container > button').click()
|
||||||
|
|
||||||
|
cy.get('.clr-treenode-link').then((innerNodes: any) => {
|
||||||
|
for (let innerNode of innerNodes) {
|
||||||
|
if (innerNode.innerText.toLowerCase().includes(tablename)) {
|
||||||
|
innerNode.click()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachFile = (filename: string, callback?: any) => {
|
||||||
|
cy.get('.buttonBar button:last-child')
|
||||||
|
.should('exist')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get('input[type="file"]#file-upload')
|
||||||
|
.attachFile(`/${fixturePath}/${filename}`)
|
||||||
|
.then(() => {
|
||||||
|
if (callback) callback()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitPage = (url: string) => {
|
||||||
|
cy.visit(`${hostUrl}${appLocation}/#/${url}`)
|
||||||
|
}
|
||||||
@@ -309,6 +309,83 @@ context('excel tests: ', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('22 | Uploads password protected Excel and unlocks with correct password', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
cy.get('.buttonBar button:last-child')
|
||||||
|
.should('exist')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get('input[type="file"]#file-upload')
|
||||||
|
.attachFile(`/${fixturePath}/regular_excel_password.xlsx`)
|
||||||
|
.then(() => {
|
||||||
|
// Wait for password modal to appear
|
||||||
|
cy.get('#filePasswordInput', { timeout: 10000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.type('123123')
|
||||||
|
|
||||||
|
// Click Unlock button
|
||||||
|
cy.get('.btn.btn-success-outline').should('not.be.disabled').click()
|
||||||
|
|
||||||
|
// Click away the overlay
|
||||||
|
cy.get('.modal-footer .btn.btn-primary', { timeout: 5000 }).click()
|
||||||
|
|
||||||
|
// Verify file loads successfully
|
||||||
|
cy.get('.btn-upload-preview', { timeout: 60000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.then(() => {
|
||||||
|
submitExcel()
|
||||||
|
rejectExcel(done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('23 | Uploads password protected Excel and handles wrong password', (done) => {
|
||||||
|
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
|
||||||
|
|
||||||
|
cy.get('.buttonBar button:last-child')
|
||||||
|
.should('exist')
|
||||||
|
.click()
|
||||||
|
.then(() => {
|
||||||
|
cy.get('input[type="file"]#file-upload')
|
||||||
|
.attachFile(`/${fixturePath}/regular_excel_password.xlsx`)
|
||||||
|
.then(() => {
|
||||||
|
// First attempt: Enter wrong password
|
||||||
|
cy.get('#filePasswordInput', { timeout: 10000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.type('wrongpassword')
|
||||||
|
|
||||||
|
cy.get('.btn.btn-success-outline').should('not.be.disabled').click()
|
||||||
|
|
||||||
|
// Verify error message appears
|
||||||
|
cy.get('.modal-footer .color-red', { timeout: 10000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.should('contain', "Sorry that didn't work, try again.")
|
||||||
|
|
||||||
|
// Modal should still be open for retry
|
||||||
|
cy.get('#filePasswordInput')
|
||||||
|
.should('be.visible')
|
||||||
|
.clear()
|
||||||
|
.type('123123')
|
||||||
|
|
||||||
|
// Second attempt: Enter correct password
|
||||||
|
cy.get('.btn.btn-success-outline').should('not.be.disabled').click()
|
||||||
|
|
||||||
|
// Click away the overlay
|
||||||
|
cy.get('.modal-footer .btn.btn-primary', { timeout: 5000 }).click()
|
||||||
|
|
||||||
|
// Verify file loads successfully
|
||||||
|
cy.get('.btn-upload-preview', { timeout: 60000 })
|
||||||
|
.should('be.visible')
|
||||||
|
.then(() => {
|
||||||
|
submitExcel()
|
||||||
|
rejectExcel(done)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Large files break Cypress
|
// Large files break Cypress
|
||||||
|
|
||||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||||
@@ -462,7 +539,9 @@ const checkResultOfFormulaUpload = (callback?: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const checkResultOfXLSUpload = (callback?: any) => {
|
const checkResultOfXLSUpload = (callback?: any) => {
|
||||||
cy.viewport(1280, 720)
|
// Config-default width — wide enough that the date/datetime/time columns are
|
||||||
|
// not virtualized away (the old 1280 width hid the trailing time columns).
|
||||||
|
cy.viewport(1600, 900)
|
||||||
cy.get('#hotTable', { timeout: 30000 })
|
cy.get('#hotTable', { timeout: 30000 })
|
||||||
.find('div.ht_master.handsontable')
|
.find('div.ht_master.handsontable')
|
||||||
.find('div.wtHolder')
|
.find('div.wtHolder')
|
||||||
@@ -480,14 +559,15 @@ const checkResultOfXLSUpload = (callback?: any) => {
|
|||||||
expect(cell.innerText).to.equal('▼\nOption 1')
|
expect(cell.innerText).to.equal('▼\nOption 1')
|
||||||
cell = data[0].children[0].children[5]
|
cell = data[0].children[0].children[5]
|
||||||
expect(cell.innerText).to.equal('42')
|
expect(cell.innerText).to.equal('42')
|
||||||
|
// Cells render locale-formatted (Cypress pins --lang=en-GB); stored value
|
||||||
|
// stays raw ISO. date -> dd/mm/yyyy, datetime -> dd/mm/yyyy, HH:mm:ss,
|
||||||
|
// time -> HH:mm:ss (en-GB 24h, identical to the raw ISO time).
|
||||||
cell = data[0].children[0].children[6]
|
cell = data[0].children[0].children[6]
|
||||||
expect(cell.innerText).to.equal('▼\n1960-02-12')
|
expect(cell.innerText).to.equal('12/02/1960')
|
||||||
// When CI detached browser screen is smaller, below cells are not visible so test fails
|
cell = data[0].children[0].children[7]
|
||||||
// Commenting it out now until we figure out workaround
|
expect(cell.innerText).to.equal('01/01/1960, 00:00:42')
|
||||||
// cell = data[0].children[0].children[7]
|
cell = data[0].children[0].children[8]
|
||||||
// expect(cell.innerText).to.equal('▼\n1960-01-01 00:00:42')
|
expect(cell.innerText).to.equal('00:00:42')
|
||||||
// cell = data[0].children[0].children[8]
|
|
||||||
// expect(cell.innerText).to.equal('00:00:42')
|
|
||||||
|
|
||||||
if (callback) callback()
|
if (callback) callback()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_
|
|||||||
2,even more dummy data,Option 3,42,12FEB1960,01JAN1960:00:00:42,0:02:22,3,44
|
2,even more dummy data,Option 3,42,12FEB1960,01JAN1960:00:00:42,0:02:22,3,44
|
||||||
3,"It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:",Option 2,1613.001,27FEB1961,01JAN1960:00:07:03,0:00:44,3,44
|
3,"It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told: It was a dark and stormy night. The wind was blowing a gale! The captain said to his mate - mate, tell us a tale. And this, is the tale he told:",Option 2,1613.001,27FEB1961,01JAN1960:00:07:03,0:00:44,3,44
|
||||||
4,if you can fill the unforgiving minute,Option 1,1613.0011235,02AUG1971,29MAY1973:06:12:03,0:06:52,3,44
|
4,if you can fill the unforgiving minute,Option 1,1613.0011235,02AUG1971,29MAY1973:06:12:03,0:06:52,3,44
|
||||||
1010,10 bottles of beer on the wall,Option 1,0.9153696885,04MAR1962,01JAN1960:12:47:55,0:01:40,92,76
|
1010,"10 bottles of beer
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
on the wall",Option 1,0.9153696885,04MAR1962,01JAN1960:12:47:55,0:01:40,92,76
|
||||||
1011,11 bottles of beer on the wall,Option 1,0.3531217558,29MAR1960,01JAN1960:03:33:24,0:01:03,80,29
|
1011,11 bottles of beer on the wall,Option 1,0.3531217558,29MAR1960,01JAN1960:03:33:24,0:01:03,80,29
|
||||||
1012,12 bottles of beer on the wall,Option 1,0.6743748717,02AUG1962,01JAN1960:07:25:59,0:00:10,16,98
|
1012,12 bottles of beer on the wall,Option 1,0.6743748717,02AUG1962,01JAN1960:07:25:59,0:00:10,16,98
|
||||||
1013,13 bottles of beer on the wall,Option 1,0.1305445992,11SEP1960,01JAN1960:13:51:32,0:00:35,73,15
|
1013,13 bottles of beer on the wall,Option 1,0.1305445992,11SEP1960,01JAN1960:13:51:32,0:00:35,73,15
|
||||||
|
|||||||
|
Binary file not shown.
@@ -20,4 +20,16 @@ import './commands'
|
|||||||
// require('./commands')
|
// require('./commands')
|
||||||
|
|
||||||
import 'cypress-plugin-tab'
|
import 'cypress-plugin-tab'
|
||||||
import "cypress-real-events"
|
import 'cypress-real-events'
|
||||||
|
|
||||||
|
// Pin the locale
|
||||||
|
Cypress.on('window:before:load', (win) => {
|
||||||
|
Object.defineProperty(win.navigator, 'language', {
|
||||||
|
value: 'en-GB',
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
Object.defineProperty(win.navigator, 'languages', {
|
||||||
|
value: ['en-GB'],
|
||||||
|
configurable: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Binary file not shown.
@@ -10,7 +10,7 @@ const check = (cwd) => {
|
|||||||
onlyAllow:
|
onlyAllow:
|
||||||
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
|
||||||
excludePackages:
|
excludePackages:
|
||||||
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;handsontable@^16.0.1;handsontable@16.2.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1'
|
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
|
||||||
},
|
},
|
||||||
(error, json) => {
|
(error, json) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ module.exports = {
|
|||||||
ci: {
|
ci: {
|
||||||
collect: {
|
collect: {
|
||||||
settings: {
|
settings: {
|
||||||
preset: "desktop",
|
preset: 'desktop',
|
||||||
chromeFlags: "--no-sandbox --disable-dev-shm-usage"
|
chromeFlags: '--no-sandbox --disable-dev-shm-usage'
|
||||||
},
|
},
|
||||||
url: [
|
url: [
|
||||||
'http://localhost:5000/AppStream/clickme/#/home/tables',
|
'http://localhost:5000/AppStream/clickme/#/home/tables',
|
||||||
@@ -37,6 +37,10 @@ module.exports = {
|
|||||||
{ minScore: 0.4, aggregationMethod: 'median' }
|
{ minScore: 0.4, aggregationMethod: 'median' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
target: 'filesystem',
|
||||||
|
outputDir: './lighthouse-reports'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+6809
-6453
File diff suppressed because it is too large
Load Diff
+29
-23
@@ -23,7 +23,7 @@
|
|||||||
"watch": "ng test watch=true",
|
"watch": "ng test watch=true",
|
||||||
"pree2e": "webdriver-manager update",
|
"pree2e": "webdriver-manager update",
|
||||||
"e2e": "protractor protractor.config.js",
|
"e2e": "protractor protractor.config.js",
|
||||||
"postinstall": "node ./src/version.ts && npm run add-githook",
|
"postinstall": "node ./src/version.ts && npm run add-githook && node ./scripts/strip-clr-base64-fonts.mjs && node ./scripts/gen-hot-icons.mjs",
|
||||||
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
"add-githook": "[ -d ../.git ] && git config core.hooksPath ./.git-hooks || true",
|
||||||
"cypress": "cypress open",
|
"cypress": "cypress open",
|
||||||
"cy:run": "cypress run",
|
"cy:run": "cypress run",
|
||||||
@@ -37,21 +37,21 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.2.17",
|
"@angular/animations": "^19.2.20",
|
||||||
"@angular/cdk": "^19.2.19",
|
"@angular/cdk": "^19.2.19",
|
||||||
"@angular/common": "^19.2.17",
|
"@angular/common": "^19.2.20",
|
||||||
"@angular/compiler": "^19.2.17",
|
"@angular/compiler": "^19.2.20",
|
||||||
"@angular/core": "^19.2.17",
|
"@angular/core": "^19.2.20",
|
||||||
"@angular/forms": "^19.2.17",
|
"@angular/forms": "^19.2.20",
|
||||||
"@angular/platform-browser": "^19.2.17",
|
"@angular/platform-browser": "^19.2.20",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.17",
|
"@angular/platform-browser-dynamic": "^19.2.20",
|
||||||
"@angular/router": "^19.2.17",
|
"@angular/router": "^19.2.20",
|
||||||
"@cds/core": "^6.15.1",
|
"@cds/core": "^6.15.1",
|
||||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||||
"@clr/icons": "^13.0.2",
|
"@clr/icons": "^13.0.2",
|
||||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||||
"@handsontable/angular-wrapper": "16.0.1",
|
"@handsontable/angular-wrapper": "^17.1.0",
|
||||||
"@sasjs/adapter": "^4.16.0",
|
"@sasjs/adapter": "^4.17.0",
|
||||||
"@sasjs/utils": "^3.5.3",
|
"@sasjs/utils": "^3.5.3",
|
||||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||||
"@types/d3-graphviz": "^2.6.7",
|
"@types/d3-graphviz": "^2.6.7",
|
||||||
@@ -61,13 +61,14 @@
|
|||||||
"crypto-browserify": "^3.12.1",
|
"crypto-browserify": "^3.12.1",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"d3-graphviz": "^5.0.2",
|
"d3-graphviz": "^5.0.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"fs-extra": "^7.0.1",
|
"fs-extra": "^7.0.1",
|
||||||
"handsontable": "^16.0.1",
|
"handsontable": "^17.1.0",
|
||||||
"https-browserify": "1.0.0",
|
"https-browserify": "1.0.0",
|
||||||
"hyperformula": "^2.5.0",
|
"hyperformula": "^2.5.0",
|
||||||
"iconv-lite": "^0.5.0",
|
"iconv-lite": "^0.5.0",
|
||||||
"jquery-datetimepicker": "^2.5.21",
|
"jquery-datetimepicker": "^2.5.21",
|
||||||
"jsrsasign": "^11.1.0",
|
"jsrsasign": "11.1.1",
|
||||||
"marked": "^5.0.0",
|
"marked": "^5.0.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"ngx-clipboard": "^16.0.0",
|
"ngx-clipboard": "^16.0.0",
|
||||||
@@ -82,22 +83,22 @@
|
|||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"vm": "^0.1.0",
|
"vm": "^0.1.0",
|
||||||
"webpack": "^5.91.0",
|
"webpack": "^5.91.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "file:libraries/xlsx-0.20.3.tgz",
|
||||||
"zone.js": "~0.15.1"
|
"zone.js": "~0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.2.19",
|
"@angular-devkit/build-angular": "^19.2.24",
|
||||||
"@angular-eslint/builder": "19.8.1",
|
"@angular-eslint/builder": "19.8.1",
|
||||||
"@angular-eslint/eslint-plugin": "19.8.1",
|
"@angular-eslint/eslint-plugin": "19.8.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
||||||
"@angular-eslint/schematics": "19.8.1",
|
"@angular-eslint/schematics": "19.8.1",
|
||||||
"@angular-eslint/template-parser": "19.8.1",
|
"@angular-eslint/template-parser": "19.8.1",
|
||||||
"@angular/cli": "^19.2.19",
|
"@angular/cli": "^19.2.24",
|
||||||
"@angular/compiler-cli": "^19.2.17",
|
"@angular/compiler-cli": "^19.2.20",
|
||||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||||
"@compodoc/compodoc": "^1.1.21",
|
"@compodoc/compodoc": "^1.2.1",
|
||||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||||
"@lhci/cli": "^0.12.0",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@types/core-js": "^2.5.5",
|
"@types/core-js": "^2.5.5",
|
||||||
"@types/crypto-js": "^4.2.1",
|
"@types/crypto-js": "^4.2.1",
|
||||||
"@types/es6-shim": "^0.31.39",
|
"@types/es6-shim": "^0.31.39",
|
||||||
@@ -105,15 +106,15 @@
|
|||||||
"@types/lodash-es": "^4.17.3",
|
"@types/lodash-es": "^4.17.3",
|
||||||
"@types/marked": "^4.3.0",
|
"@types/marked": "^4.3.0",
|
||||||
"@types/node": "12.20.50",
|
"@types/node": "12.20.50",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||||
"@typescript-eslint/parser": "^5.29.0",
|
"@typescript-eslint/parser": "8.31.1",
|
||||||
"core-js": "^2.5.4",
|
"core-js": "^2.5.4",
|
||||||
"cypress": "12.17.1",
|
"cypress": "^15.14.2",
|
||||||
"cypress-file-upload": "^5.0.8",
|
"cypress-file-upload": "^5.0.8",
|
||||||
"cypress-plugin-tab": "^1.0.5",
|
"cypress-plugin-tab": "^1.0.5",
|
||||||
"cypress-real-events": "^1.8.1",
|
"cypress-real-events": "^1.8.1",
|
||||||
"es6-shim": "^0.35.5",
|
"es6-shim": "^0.35.5",
|
||||||
"eslint": "^8.33.0",
|
"eslint": "8.57.1",
|
||||||
"git-describe": "^4.0.4",
|
"git-describe": "^4.0.4",
|
||||||
"jasmine-core": "~5.1.2",
|
"jasmine-core": "~5.1.2",
|
||||||
"karma": "~6.4.3",
|
"karma": "~6.4.3",
|
||||||
@@ -132,5 +133,10 @@
|
|||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"wait-on": "^6.0.1",
|
"wait-on": "^6.0.1",
|
||||||
"watch": "^1.0.2"
|
"watch": "^1.0.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"ajv": "8.18.0",
|
||||||
|
"uuid": "11.1.1",
|
||||||
|
"lighthouse": "13.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { resolve, join } from 'path'
|
||||||
|
import { createRequire } from 'module'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate static SVG assets + an SCSS partial that re-applies HOT v17 classic
|
||||||
|
* theme icons via real URLs (not data: URIs).
|
||||||
|
*
|
||||||
|
* Why: deployed app runs under CSP `img-src 'self'`. HOT v17's classic theme
|
||||||
|
* embeds icons as `data:image/svg+xml,...` in `-webkit-mask-image` rules, which
|
||||||
|
* the CSP blocks. We switch to `ht-theme-classic-no-icons.min.css` and re-add
|
||||||
|
* the icon rules pointing at same-origin SVG files emitted from this script.
|
||||||
|
*
|
||||||
|
* Inputs (HOT's own modules, so semantic names + selector list track upstream):
|
||||||
|
* handsontable/themes/theme/classic → { classicTheme: { icons } }
|
||||||
|
* handsontable/themes/static/variables/helpers/iconsMap → iconsMap(icons, themePrefix)
|
||||||
|
*
|
||||||
|
* Outputs:
|
||||||
|
* client/src/assets/hot-icons/<kebab-name>.svg
|
||||||
|
* client/src/_hot-icons.scss
|
||||||
|
*
|
||||||
|
* Idempotent: clears the output dir and rewrites both outputs each run.
|
||||||
|
* Skips silently if handsontable isn't installed yet (pre-install runs).
|
||||||
|
*/
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
const ASSETS_DIR = resolve('src/assets/hot-icons')
|
||||||
|
const SCSS_OUT = resolve('src/_hot-icons.scss')
|
||||||
|
const ASSET_URL_PREFIX = './assets/hot-icons/'
|
||||||
|
|
||||||
|
const themePath = resolve('node_modules/handsontable/themes/theme/classic.js')
|
||||||
|
const mapPath = resolve('node_modules/handsontable/themes/static/variables/helpers/iconsMap.js')
|
||||||
|
|
||||||
|
if (!existsSync(themePath) || !existsSync(mapPath)) {
|
||||||
|
console.log('skip: handsontable theme modules not found (likely pre-install run)')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { classicTheme } = require(themePath)
|
||||||
|
const { iconsMap } = require(mapPath)
|
||||||
|
|
||||||
|
const icons = classicTheme.icons
|
||||||
|
const cssTemplate = iconsMap(icons, 'ht-theme-classic')
|
||||||
|
|
||||||
|
const kebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||||
|
|
||||||
|
rmSync(ASSETS_DIR, { recursive: true, force: true })
|
||||||
|
mkdirSync(ASSETS_DIR, { recursive: true })
|
||||||
|
|
||||||
|
const writeMap = {}
|
||||||
|
for (const [name, dataUri] of Object.entries(icons)) {
|
||||||
|
if (typeof dataUri !== 'string' || !dataUri.startsWith('data:image/svg+xml')) continue
|
||||||
|
const decoded = decodeURIComponent(dataUri.replace(/^data:image\/svg\+xml(;charset=utf-8)?,/, ''))
|
||||||
|
const fname = kebab(name) + '.svg'
|
||||||
|
writeFileSync(join(ASSETS_DIR, fname), decoded)
|
||||||
|
writeMap[dataUri] = ASSET_URL_PREFIX + fname
|
||||||
|
}
|
||||||
|
|
||||||
|
let scss = cssTemplate
|
||||||
|
for (const [uri, url] of Object.entries(writeMap)) {
|
||||||
|
scss = scss.split(`url("${uri}")`).join(`url("${url}")`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = '/* Auto-generated by scripts/gen-hot-icons.mjs — do not edit by hand.\n' +
|
||||||
|
' Regenerated on postinstall; rerun manually via `node scripts/gen-hot-icons.mjs`. */\n\n'
|
||||||
|
|
||||||
|
writeFileSync(SCSS_OUT, header + scss + '\n')
|
||||||
|
|
||||||
|
console.log(`hot-icons: wrote ${Object.keys(writeMap).length} SVGs + ${SCSS_OUT}`)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { readFileSync, writeFileSync, statSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Clarity's Metropolis @font-face blocks from clr-ui.min.css.
|
||||||
|
*
|
||||||
|
* Why: Clarity ships Metropolis as base64 data: URLs. The deployed app
|
||||||
|
* runs under CSP `default-src 'self'` (no data: font-src), so every page
|
||||||
|
* logs a font-load failure for each weight. Firefox preemptively
|
||||||
|
* validates every parsed src against CSP even when a later @font-face
|
||||||
|
* supersedes the rule at render time, so the only way to silence the
|
||||||
|
* console is to remove the offending blocks from the parsed CSS.
|
||||||
|
*
|
||||||
|
* Our styles.scss declares the same family/weight/style with same-origin
|
||||||
|
* .woff files, so removing Clarity's blocks entirely is safe and leaves
|
||||||
|
* Metropolis fully functional.
|
||||||
|
*
|
||||||
|
* Idempotent: matches by font-family, so works on a fresh install or a
|
||||||
|
* file that's already been stripped on a previous run.
|
||||||
|
*/
|
||||||
|
const target = resolve('node_modules/@clr/ui/clr-ui.min.css')
|
||||||
|
|
||||||
|
let css
|
||||||
|
try {
|
||||||
|
css = readFileSync(target, 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
console.log(`skip: ${target} not found (likely pre-install run)`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeBefore = statSync(target).size
|
||||||
|
const blockRe = /@font-face\{[^}]*Metropolis[^}]*\}/g
|
||||||
|
const matches = css.match(blockRe) ?? []
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
console.log(`already stripped: ${target}`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripped = css.replace(blockRe, '')
|
||||||
|
writeFileSync(target, stripped)
|
||||||
|
const sizeAfter = Buffer.byteLength(stripped)
|
||||||
|
console.log(
|
||||||
|
`removed ${matches.length} Metropolis @font-face block(s) from clr-ui.min.css ` +
|
||||||
|
`(${sizeBefore} -> ${sizeAfter} bytes, saved ${sizeBefore - sizeAfter})`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Webpack 5's persistent cache treats node_modules as immutable
|
||||||
|
// (snapshot.module.managedPaths default), so in-place edits don't
|
||||||
|
// invalidate cached entries. Drop the Angular build cache so the next
|
||||||
|
// build re-reads our stripped clr-ui.min.css.
|
||||||
|
const cacheDir = resolve('.angular/cache')
|
||||||
|
if (existsSync(cacheDir)) {
|
||||||
|
rmSync(cacheDir, { recursive: true, force: true })
|
||||||
|
console.log(`cleared ${cacheDir} (webpack persistent cache)`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/* Auto-generated by scripts/gen-hot-icons.mjs — do not edit by hand.
|
||||||
|
Regenerated on postinstall; rerun manually via `node scripts/gen-hot-icons.mjs`. */
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
|
[class*=ht-theme-classic] .htContextMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
|
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td.htSubmenu .htItemWrapper::after,
|
||||||
|
[class*=ht-theme-classic] .pika-single .pika-next {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .pika-single .pika-prev {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-size-section__select-wrapper::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-down.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .changeType::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/select-arrow.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htUISelectCaption::after,
|
||||||
|
.htAutocompleteArrow::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/select-arrow.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .columnSorting.sortAction.ascending::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-narrow-up.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .columnSorting.sortAction.descending::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-narrow-down.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-first::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-first::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-prev::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-prev::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-next::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-next::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-page-navigation-section .ht-page-last::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-right-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] [dir="rtl"] .ht-page-navigation-section .ht-page-last::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/arrow-left-with-bar.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htDropdownMenu table tbody tr td .htItemWrapper span.selected::after,
|
||||||
|
[class*=ht-theme-classic] .htContextMenu table tbody tr td .htItemWrapper span.selected::after,
|
||||||
|
[class*=ht-theme-classic] .htFiltersConditionsMenu table tbody tr td .htItemWrapper span.selected::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/check.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htCheckboxRendererInput {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htCheckboxRendererInput::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/checkbox.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.beforeHiddenColumn::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-left.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.afterHiddenColumn::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-right.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.beforeHiddenRow::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-up.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] th.afterHiddenRow::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/caret-hidden-down.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .collapsibleIndicator::before,
|
||||||
|
[class*=ht-theme-classic] .ht_nestingButton::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/collapse-off.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .collapsibleIndicator.collapsed::before,
|
||||||
|
[class*=ht-theme-classic] .ht_nestingButton.ht_nestingExpand::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/collapse-on.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .htUIRadio > input[type="radio"]::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/radio.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-multi-select-chip-remove::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/chip-close.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-notification__close::before {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/chip-close.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-multi-select-editor-search-icon {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/search.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
[class*=ht-theme-classic] .ht-multi-select-editor-item-selected input::after {
|
||||||
|
width: var(--ht-icon-size);
|
||||||
|
height: var(--ht-icon-size);
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-image: url("./assets/hot-icons/checkbox.svg");
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ export interface HandsontableStaticConfig {
|
|||||||
* Cached viyaApi collections, search and selected endpoint
|
* Cached viyaApi collections, search and selected endpoint
|
||||||
*/
|
*/
|
||||||
export const globals: {
|
export const globals: {
|
||||||
|
embed: boolean
|
||||||
rootParam: string
|
rootParam: string
|
||||||
dcLib: string
|
dcLib: string
|
||||||
xlmaps: XLMapListItem[]
|
xlmaps: XLMapListItem[]
|
||||||
@@ -69,6 +70,7 @@ export const globals: {
|
|||||||
handsontable: HandsontableStaticConfig
|
handsontable: HandsontableStaticConfig
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
} = {
|
} = {
|
||||||
|
embed: false,
|
||||||
rootParam: <string>'',
|
rootParam: <string>'',
|
||||||
dcLib: '',
|
dcLib: '',
|
||||||
xlmaps: [],
|
xlmaps: [],
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<header class="app-header">
|
<header class="app-header" *ngIf="!embed">
|
||||||
<!-- <button
|
<!-- <button
|
||||||
*ngIf="
|
*ngIf="
|
||||||
isMainRoute('view') ||
|
isMainRoute('view') ||
|
||||||
@@ -127,9 +127,10 @@
|
|||||||
"
|
"
|
||||||
(click)="toggleSidebar()"
|
(click)="toggleSidebar()"
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label="Toggle sidebar"
|
||||||
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
|
class="cursor-pointer select-none ml-10 d-flex clr-justify-content-center clr-align-items-center"
|
||||||
>
|
>
|
||||||
<clr-icon size="24" shape="tree-view"></clr-icon>
|
<clr-icon size="24" shape="tree-view" aria-hidden="true"></clr-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="logo d-flex clr-align-items-center">
|
<div class="logo d-flex clr-align-items-center">
|
||||||
@@ -213,9 +214,10 @@
|
|||||||
</header>
|
</header>
|
||||||
<nav
|
<nav
|
||||||
*ngIf="
|
*ngIf="
|
||||||
router.url.includes('submitted') ||
|
!embed &&
|
||||||
router.url.includes('approve') ||
|
(router.url.includes('submitted') ||
|
||||||
router.url.includes('history')
|
router.url.includes('approve') ||
|
||||||
|
router.url.includes('history'))
|
||||||
"
|
"
|
||||||
class="subnav"
|
class="subnav"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export class AppComponent {
|
|||||||
|
|
||||||
public syssite = this.appService.syssite
|
public syssite = this.appService.syssite
|
||||||
public licenceState = this.licenceService.licenceState
|
public licenceState = this.licenceService.licenceState
|
||||||
|
public embed = globals.embed
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private appService: AppService,
|
private appService: AppService,
|
||||||
@@ -143,6 +144,16 @@ export class AppComponent {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hashQuery = window.location.hash.split('?')[1]
|
||||||
|
if (hashQuery) {
|
||||||
|
const embedParam = new URLSearchParams(hashQuery).get('embed')
|
||||||
|
if (embedParam !== null) {
|
||||||
|
const isEmbed = embedParam !== 'false'
|
||||||
|
globals.embed = isEmbed
|
||||||
|
this.embed = isEmbed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.subscribeToShowAbortModal()
|
this.subscribeToShowAbortModal()
|
||||||
this.subscribeToRequestsModal()
|
this.subscribeToRequestsModal()
|
||||||
this.subscribeToStartupData()
|
this.subscribeToStartupData()
|
||||||
@@ -198,6 +209,7 @@ export class AppComponent {
|
|||||||
dcPath: getAppAttribute('dcPath') || '',
|
dcPath: getAppAttribute('dcPath') || '',
|
||||||
debug: getAppAttribute('debug') === 'true' || false,
|
debug: getAppAttribute('debug') === 'true' || false,
|
||||||
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
||||||
|
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
|
||||||
contextName: getAppAttribute('contextName') || '',
|
contextName: getAppAttribute('contextName') || '',
|
||||||
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-1
@@ -1,5 +1,4 @@
|
|||||||
declare module 'save-svg-as-png'
|
declare module 'save-svg-as-png'
|
||||||
declare module 'numbro/dist/languages.min'
|
|
||||||
declare interface Navigator {
|
declare interface Navigator {
|
||||||
msSaveBlob: (blob: any, defaultName?: string) => boolean
|
msSaveBlob: (blob: any, defaultName?: string) => boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,7 @@ import { RouterModule, Routes } from '@angular/router'
|
|||||||
|
|
||||||
import { NotFoundComponent } from './not-found/not-found.component'
|
import { NotFoundComponent } from './not-found/not-found.component'
|
||||||
|
|
||||||
import { DeployModule } from './deploy/deploy.module'
|
|
||||||
import { EditorModule } from './editor/editor.module'
|
|
||||||
import { HomeModule } from './home/home.module'
|
|
||||||
import { LicensingModule } from './licensing/licensing.module'
|
|
||||||
import { ReviewModule } from './review/review.module'
|
|
||||||
import { ReviewRouteComponent } from './routes/review-route/review-route.component'
|
import { ReviewRouteComponent } from './routes/review-route/review-route.component'
|
||||||
import { StageModule } from './stage/stage.module'
|
|
||||||
import { SystemModule } from './system/system.module'
|
|
||||||
import { ViewerModule } from './viewer/viewer.module'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defining routes
|
* Defining routes
|
||||||
@@ -25,7 +17,8 @@ export const ROUTES: Routes = [
|
|||||||
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
||||||
{
|
{
|
||||||
path: 'view',
|
path: 'view',
|
||||||
loadChildren: () => ViewerModule
|
loadChildren: () =>
|
||||||
|
import('./viewer/viewer.module').then((m) => m.ViewerModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
@@ -37,36 +30,42 @@ export const ROUTES: Routes = [
|
|||||||
{ path: '', pathMatch: 'full', redirectTo: 'toapprove' },
|
{ path: '', pathMatch: 'full', redirectTo: 'toapprove' },
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadChildren: () => ReviewModule
|
loadChildren: () =>
|
||||||
|
import('./review/review.module').then((m) => m.ReviewModule)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'licensing',
|
path: 'licensing',
|
||||||
loadChildren: () => LicensingModule
|
loadChildren: () =>
|
||||||
|
import('./licensing/licensing.module').then((m) => m.LicensingModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
loadChildren: () => HomeModule
|
loadChildren: () => import('./home/home.module').then((m) => m.HomeModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Load editor module with subroutes
|
* Load editor module with subroutes
|
||||||
*/
|
*/
|
||||||
path: 'editor',
|
path: 'editor',
|
||||||
loadChildren: () => EditorModule
|
loadChildren: () =>
|
||||||
|
import('./editor/editor.module').then((m) => m.EditorModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'stage',
|
path: 'stage',
|
||||||
loadChildren: () => StageModule
|
loadChildren: () =>
|
||||||
|
import('./stage/stage.module').then((m) => m.StageModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'system',
|
path: 'system',
|
||||||
loadChildren: () => SystemModule
|
loadChildren: () =>
|
||||||
|
import('./system/system.module').then((m) => m.SystemModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'deploy',
|
path: 'deploy',
|
||||||
loadChildren: () => DeployModule
|
loadChildren: () =>
|
||||||
|
import('./deploy/deploy.module').then((m) => m.DeployModule)
|
||||||
},
|
},
|
||||||
{ path: '**', component: NotFoundComponent }
|
{ path: '**', component: NotFoundComponent }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="content-area position-relative">
|
<main class="content-area position-relative">
|
||||||
<div class="clr-row">
|
<div class="clr-row">
|
||||||
<!-- T&C section -->
|
<!-- T&C section -->
|
||||||
<div *ngIf="step === 0" id="TCS" class="card">
|
<div *ngIf="step === 0" id="TCS" class="card">
|
||||||
@@ -97,4 +97,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</main>
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ export class AutomaticComponent implements OnInit {
|
|||||||
let contextname = `&_contextname=${params.contextName}`
|
let contextname = `&_contextname=${params.contextName}`
|
||||||
let admin = `&admin=${params.admin}`
|
let admin = `&admin=${params.admin}`
|
||||||
let dcPath = `&dcpath=${params.dcPath}`
|
let dcPath = `&dcpath=${params.dcPath}`
|
||||||
let debug = `&_debug=131`
|
let debug = this.sasService.getDebugUrlParam()
|
||||||
|
|
||||||
let programUrl =
|
let programUrl =
|
||||||
serverUrl +
|
serverUrl +
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export class ManualComponent implements OnInit {
|
|||||||
this.selectedAdminGroup +
|
this.selectedAdminGroup +
|
||||||
'&DCPATH=' +
|
'&DCPATH=' +
|
||||||
this.dcPath +
|
this.dcPath +
|
||||||
'&_debug=131'
|
this.sasService.getDebugUrlParam()
|
||||||
|
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
|
|
||||||
|
|||||||
@@ -177,45 +177,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<clr-textarea-container class="date-field" *ngSwitchCase="'time'">
|
<div class="date-field" *ngSwitchCase="'intl-time'">
|
||||||
<textarea
|
<input
|
||||||
clrTextarea
|
type="time"
|
||||||
(paste)="recordInputPaste($event)"
|
step="1"
|
||||||
(input)="recordInputChange($event, col.key)"
|
class="date-input"
|
||||||
|
[attr.aria-label]="col.key"
|
||||||
[class.invalid-data]="
|
[class.invalid-data]="
|
||||||
currentRecordInvalidCols.includes(col.key)
|
currentRecordInvalidCols.includes(col.key)
|
||||||
"
|
"
|
||||||
[rows]="col.value.length > 80 ? 6 : 1"
|
[value]="currentRecord[col.key]"
|
||||||
[(ngModel)]="currentRecord[col.key]"
|
(change)="recordTimeChange($event, col.key)"
|
||||||
[class.not-char]="
|
/>
|
||||||
currentRecordValidator?.getRule(col.key)?.type
|
</div>
|
||||||
"
|
|
||||||
></textarea>
|
|
||||||
<clr-control-helper>HH:mm:ss</clr-control-helper>
|
|
||||||
</clr-textarea-container>
|
|
||||||
|
|
||||||
<div class="date-field" *ngSwitchCase="'date'">
|
<div class="date-field" *ngSwitchCase="'intl-datetime'">
|
||||||
<textarea
|
<input
|
||||||
clrTextarea
|
type="datetime-local"
|
||||||
(paste)="recordInputPaste($event)"
|
step="1"
|
||||||
(input)="recordInputChange($event, col.key)"
|
class="date-input"
|
||||||
|
[attr.aria-label]="col.key"
|
||||||
[class.invalid-data]="
|
[class.invalid-data]="
|
||||||
currentRecordInvalidCols.includes(col.key)
|
currentRecordInvalidCols.includes(col.key)
|
||||||
"
|
"
|
||||||
rows="1"
|
[value]="toNativeDatetime(currentRecord[col.key])"
|
||||||
cols="auto"
|
(change)="recordDatetimeChange($event, col.key)"
|
||||||
class="not-char"
|
/>
|
||||||
[(ngModel)]="currentRecord[col.key]"
|
</div>
|
||||||
></textarea>
|
|
||||||
<clr-date-container class="date-picker">
|
<div class="date-field" *ngSwitchCase="'intl-date'">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
name="date"
|
class="date-input"
|
||||||
class="d-none"
|
[attr.aria-label]="col.key"
|
||||||
(clrDateChange)="recordDateChange($event, col.key)"
|
[class.invalid-data]="
|
||||||
clrDate
|
currentRecordInvalidCols.includes(col.key)
|
||||||
/>
|
"
|
||||||
</clr-date-container>
|
[value]="currentRecord[col.key]"
|
||||||
|
(change)="recordDateChange($event, col.key)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div *ngSwitchCase="'autocomplete'">
|
<div *ngSwitchCase="'autocomplete'">
|
||||||
<ng-container
|
<ng-container
|
||||||
@@ -255,6 +255,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline focusable"
|
class="btn btn-outline focusable"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
aria-label="Previous record"
|
||||||
(click)="onPreviousRecordClick()"
|
(click)="onPreviousRecordClick()"
|
||||||
[disabled]="currentRecordInvalidCols.length > 0"
|
[disabled]="currentRecordInvalidCols.length > 0"
|
||||||
>
|
>
|
||||||
@@ -267,6 +268,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline focusable"
|
class="btn btn-outline focusable"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
aria-label="Next record"
|
||||||
(click)="onNextRecordClick()"
|
(click)="onNextRecordClick()"
|
||||||
[disabled]="currentRecordInvalidCols.length > 0"
|
[disabled]="currentRecordInvalidCols.length > 0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import { KeyValue } from '@angular/common'
|
|||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
Input,
|
Input,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
ViewEncapsulation
|
ViewEncapsulation
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import moment from 'moment'
|
|
||||||
import { ValidateFilterSASResponse } from 'src/app/models/sas/validate-filter.model'
|
import { ValidateFilterSASResponse } from 'src/app/models/sas/validate-filter.model'
|
||||||
import { QueryClause } from 'src/app/models/TableData'
|
import { QueryClause } from 'src/app/models/TableData'
|
||||||
import { HelperService } from 'src/app/services/helper.service'
|
import { HelperService } from 'src/app/services/helper.service'
|
||||||
import { SasStoreService } from 'src/app/services/sas-store.service'
|
import { SasStoreService } from 'src/app/services/sas-store.service'
|
||||||
import { DcValidator } from 'src/app/shared/dc-validator/dc-validator'
|
import { DcValidator } from 'src/app/shared/dc-validator/dc-validator'
|
||||||
import { DcValidation } from 'src/app/shared/dc-validator/models/dc-validation.model'
|
import { DcValidation } from 'src/app/shared/dc-validator/models/dc-validation.model'
|
||||||
|
import { isEmpty } from 'src/app/shared/dc-validator/utils/isEmpty'
|
||||||
import {
|
import {
|
||||||
EditRecordDropdownChangeEvent,
|
EditRecordDropdownChangeEvent,
|
||||||
EditRecordInputFocusedEvent
|
EditRecordInputFocusedEvent
|
||||||
@@ -90,17 +91,69 @@ export class EditRecordComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fired when date field in the record change
|
* Fired when the native date picker (intl-date) changes. A native
|
||||||
* Function will parse date and format to string
|
* `<input type="date">` value is always ISO `YYYY-MM-DD`, so it is stored
|
||||||
* @param date picker value
|
* as-is. Mirrors recordTimeChange / recordDatetimeChange.
|
||||||
|
* @param event native <input type="date"> change event
|
||||||
* @param colKey column name (key)
|
* @param colKey column name (key)
|
||||||
*/
|
*/
|
||||||
recordDateChange(date: Date, colKey: string) {
|
recordDateChange(event: Event, colKey: string) {
|
||||||
let cellValidation = this.currentRecordValidator?.getRule(colKey)
|
const value = (event.target as HTMLInputElement).value // YYYY-MM-DD
|
||||||
let format = cellValidation ? cellValidation.dateFormat : ''
|
if (!value || !this.currentRecord) return
|
||||||
|
this.currentRecord[colKey] = value
|
||||||
|
this.revalidateRecordCol(colKey, value)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.currentRecord)
|
/**
|
||||||
this.currentRecord[colKey] = moment(date).format(format)
|
* Stored `YYYY-MM-DD HH:mm:ss` → native `<input type="datetime-local">` value
|
||||||
|
* `YYYY-MM-DDTHH:mm:ss`, so the picker opens at the current value.
|
||||||
|
*/
|
||||||
|
toNativeDatetime(value: any): string {
|
||||||
|
if (typeof value !== 'string') return ''
|
||||||
|
return value.includes(' ') ? value.replace(' ', 'T') : value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-runs validation for a column after a native picker change (the picker
|
||||||
|
* handlers set the value programmatically, which does not fire the textarea's
|
||||||
|
* input handler). Mirrors recordInputChange's validate + state update.
|
||||||
|
*/
|
||||||
|
private revalidateRecordCol(colName: string, value: any) {
|
||||||
|
const colRules = this.currentRecordValidator?.getRule(colName)
|
||||||
|
this.validateRecordCol(colRules, value).then((valid: boolean) =>
|
||||||
|
this.updateValidationState(colName, valid)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired when the native time picker (intl-time) changes. Stores 24h
|
||||||
|
* `HH:mm:ss`, padding the seconds the browser may omit. Mirrors the
|
||||||
|
* IntlDatetimeEditor conversion used by the HOT grid.
|
||||||
|
* @param event native <input type="time"> change event
|
||||||
|
* @param colKey column name (key)
|
||||||
|
*/
|
||||||
|
recordTimeChange(event: Event, colKey: string) {
|
||||||
|
const value = (event.target as HTMLInputElement).value // HH:mm[:ss]
|
||||||
|
if (!value || !this.currentRecord) return
|
||||||
|
const stored = value.length === 5 ? `${value}:00` : value
|
||||||
|
this.currentRecord[colKey] = stored
|
||||||
|
this.revalidateRecordCol(colKey, stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fired when the native datetime picker (intl-datetime) changes. Converts the
|
||||||
|
* native `YYYY-MM-DDTHH:mm[:ss]` into SAS' stored `YYYY-MM-DD HH:mm:ss`,
|
||||||
|
* padding seconds. Mirrors the IntlDatetimeEditor conversion used by the grid.
|
||||||
|
* @param event native <input type="datetime-local"> change event
|
||||||
|
* @param colKey column name (key)
|
||||||
|
*/
|
||||||
|
recordDatetimeChange(event: Event, colKey: string) {
|
||||||
|
const value = (event.target as HTMLInputElement).value // YYYY-MM-DDTHH:mm[:ss]
|
||||||
|
if (!value || !this.currentRecord) return
|
||||||
|
const [date, time] = value.split('T')
|
||||||
|
const stored = `${date} ${time.length === 5 ? `${time}:00` : time}`
|
||||||
|
this.currentRecord[colKey] = stored
|
||||||
|
this.revalidateRecordCol(colKey, stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,6 +172,16 @@ export class EditRecordComponent implements OnInit {
|
|||||||
this.onRecordEditClose.emit()
|
this.onRecordEditClose.emit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the modal on Escape (cancel, like the Close button). A native picker
|
||||||
|
* swallows the first Escape to dismiss itself, so the modal only closes once
|
||||||
|
* focus is back on the form.
|
||||||
|
*/
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscapeKey() {
|
||||||
|
this.closeRecordEdit()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emitting output event when dropdown (autocomplete) input in any col change
|
* Emitting output event when dropdown (autocomplete) input in any col change
|
||||||
* @param colName column name (key)
|
* @param colName column name (key)
|
||||||
@@ -146,23 +209,63 @@ export class EditRecordComponent implements OnInit {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordInputChange(event: any, colName: string) {
|
async recordInputChange(event: any, colName: string): Promise<void> {
|
||||||
const colRules = this.currentRecordValidator?.getRule(colName)
|
const colRules = this.currentRecordValidator?.getRule(colName)
|
||||||
const value = event.target.value
|
const value = event.target.value
|
||||||
|
|
||||||
this.helperService.debounceCall(300, () => {
|
this.helperService.debounceCall(300, () => {
|
||||||
this.validateRecordCol(colRules, value).then((valid: boolean) => {
|
this.validateRecordCol(colRules, value).then((valid: boolean) => {
|
||||||
const index = this.currentRecordInvalidCols.indexOf(colName)
|
this.updateValidationState(colName, valid)
|
||||||
|
|
||||||
if (valid) {
|
if (!valid) {
|
||||||
if (index > -1) this.currentRecordInvalidCols.splice(index, 1)
|
this.tryAutoPopulateNotNull(event, colName, colRules, value)
|
||||||
} else {
|
|
||||||
if (index < 0) this.currentRecordInvalidCols.push(colName)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the invalid columns list based on validation result
|
||||||
|
*/
|
||||||
|
private updateValidationState(colName: string, valid: boolean): void {
|
||||||
|
const index = this.currentRecordInvalidCols.indexOf(colName)
|
||||||
|
|
||||||
|
if (valid && index > -1) {
|
||||||
|
this.currentRecordInvalidCols.splice(index, 1)
|
||||||
|
} else if (!valid && index < 0) {
|
||||||
|
this.currentRecordInvalidCols.push(colName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-populates NOTNULL default value when the field is empty and has a default
|
||||||
|
*/
|
||||||
|
private tryAutoPopulateNotNull(
|
||||||
|
event: any,
|
||||||
|
colName: string,
|
||||||
|
colRules: DcValidation | undefined,
|
||||||
|
value: any
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
!isEmpty(value) ||
|
||||||
|
!this.currentRecordValidator ||
|
||||||
|
!this.currentRecord
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue =
|
||||||
|
this.currentRecordValidator.getNotNullDefaultValue(colName)
|
||||||
|
if (defaultValue === undefined) return
|
||||||
|
|
||||||
|
this.currentRecord[colName] = defaultValue
|
||||||
|
event.target.value = defaultValue
|
||||||
|
|
||||||
|
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
|
||||||
|
this.updateValidationState(colName, isValid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onNextRecordClick() {
|
onNextRecordClick() {
|
||||||
this.onNextRecord.emit()
|
this.onNextRecord.emit()
|
||||||
}
|
}
|
||||||
@@ -192,8 +295,9 @@ export class EditRecordComponent implements OnInit {
|
|||||||
if (obj.data === key) {
|
if (obj.data === key) {
|
||||||
if (
|
if (
|
||||||
obj.type === 'numeric' ||
|
obj.type === 'numeric' ||
|
||||||
obj.type === 'date' ||
|
obj.type === 'intl-date' ||
|
||||||
obj.type === 'time'
|
obj.type === 'intl-time' ||
|
||||||
|
obj.type === 'intl-datetime'
|
||||||
) {
|
) {
|
||||||
type = 'N'
|
type = 'N'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="content-area d-flex clr-flex-column">
|
<main class="content-area d-flex clr-flex-column">
|
||||||
<clr-modal
|
<clr-modal
|
||||||
appFileDrop
|
appFileDrop
|
||||||
(fileOver)="fileOverBase($event)"
|
(fileOver)="fileOverBase($event)"
|
||||||
@@ -165,21 +165,30 @@
|
|||||||
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
|
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
*ngIf="tableTrue"
|
*ngIf="tableTrue && !embed"
|
||||||
class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn"
|
class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="btn icon-collapse btn-sm btn-icon btn-dimmed"
|
class="btn icon-collapse btn-sm btn-icon btn-dimmed"
|
||||||
[routerLink]="['/home']"
|
[routerLink]="['/home']"
|
||||||
>
|
>
|
||||||
<clr-icon shape="caret" dir="left" size="20"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="caret"
|
||||||
|
dir="left"
|
||||||
|
size="20"
|
||||||
|
></clr-icon>
|
||||||
<span class="text">Back to table selection</span>
|
<span class="text">Back to table selection</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
(click)="viewboxManager()"
|
(click)="viewboxManager()"
|
||||||
class="btn icon-collapse btn-sm btn-icon btn-dimmed viewbox-open"
|
class="btn icon-collapse btn-sm btn-icon btn-dimmed viewbox-open"
|
||||||
>
|
>
|
||||||
<clr-icon shape="view-cards" size="20"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="view-cards"
|
||||||
|
size="20"
|
||||||
|
></clr-icon>
|
||||||
<span class="text">Viewboxes</span>
|
<span class="text">Viewboxes</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +216,7 @@
|
|||||||
|
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="libdsParsed.tableName.includes('-FC')"
|
*ngIf="libdsParsed.tableName.includes('-FC')"
|
||||||
|
aria-hidden="true"
|
||||||
shape="bolt"
|
shape="bolt"
|
||||||
class="color-yellow"
|
class="color-yellow"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
@@ -251,7 +261,7 @@
|
|||||||
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
|
class="btnView btn icon-collapse btn-sm btn-icon btn-block btn-dimmed"
|
||||||
(click)="openQb()"
|
(click)="openQb()"
|
||||||
>
|
>
|
||||||
<clr-icon shape="filter"></clr-icon>
|
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
|
||||||
<span class="text">Filter</span>
|
<span class="text">Filter</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -260,7 +270,7 @@
|
|||||||
class="btn icon-collapse btn-sm btn-primary btn-block"
|
class="btn icon-collapse btn-sm btn-primary btn-block"
|
||||||
(click)="editTable()"
|
(click)="editTable()"
|
||||||
>
|
>
|
||||||
<clr-icon shape="note"></clr-icon>
|
<clr-icon aria-hidden="true" shape="note"></clr-icon>
|
||||||
<span class="text">Edit</span>
|
<span class="text">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -270,7 +280,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn icon-collapse btn-sm btn-success btn-block mr-0"
|
class="btn icon-collapse btn-sm btn-success btn-block mr-0"
|
||||||
>
|
>
|
||||||
<clr-icon shape="upload"></clr-icon>
|
<clr-icon aria-hidden="true" shape="upload"></clr-icon>
|
||||||
<span class="text">Upload</span>
|
<span class="text">Upload</span>
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@@ -281,7 +291,7 @@
|
|||||||
class="btn btn-sm btn-icon btn-outline-danger"
|
class="btn btn-sm btn-icon btn-outline-danger"
|
||||||
(click)="cancelEdit()"
|
(click)="cancelEdit()"
|
||||||
>
|
>
|
||||||
<clr-icon shape="times"></clr-icon>
|
<clr-icon aria-hidden="true" shape="times"></clr-icon>
|
||||||
<span>Cancel</span>
|
<span>Cancel</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -294,7 +304,8 @@
|
|||||||
[class.dc-locked-control]="restrictions.restrictAddRow"
|
[class.dc-locked-control]="restrictions.restrictAddRow"
|
||||||
(click)="!restrictions.restrictAddRow ? addRow() : ''"
|
(click)="!restrictions.restrictAddRow ? addRow() : ''"
|
||||||
>
|
>
|
||||||
<clr-icon shape="plus" size="16"></clr-icon>Add Row
|
<clr-icon aria-hidden="true" shape="plus" size="16"></clr-icon
|
||||||
|
>Add Row
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<clr-tooltip-content
|
<clr-tooltip-content
|
||||||
@@ -319,7 +330,8 @@
|
|||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
(click)="checkSave()"
|
(click)="checkSave()"
|
||||||
>
|
>
|
||||||
<clr-icon shape="check" size="20"></clr-icon>Submit
|
<clr-icon aria-hidden="true" shape="check" size="20"></clr-icon
|
||||||
|
>Submit
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@@ -329,7 +341,7 @@
|
|||||||
class="btn btn-sm btn-icon btn-outline-danger btn-upload-preview"
|
class="btn btn-sm btn-icon btn-outline-danger btn-upload-preview"
|
||||||
(click)="discardSourceFile = true"
|
(click)="discardSourceFile = true"
|
||||||
>
|
>
|
||||||
<clr-icon shape="times"></clr-icon>
|
<clr-icon aria-hidden="true" shape="times"></clr-icon>
|
||||||
<span>Discard file</span>
|
<span>Discard file</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -339,7 +351,7 @@
|
|||||||
class="btn btn-sm btn-primary btn-upload-preview"
|
class="btn btn-sm btn-primary btn-upload-preview"
|
||||||
(click)="manualFileEditModal = true"
|
(click)="manualFileEditModal = true"
|
||||||
>
|
>
|
||||||
<clr-icon shape="note"></clr-icon>
|
<clr-icon aria-hidden="true" shape="note"></clr-icon>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -349,7 +361,7 @@
|
|||||||
(click)="submitExcel()"
|
(click)="submitExcel()"
|
||||||
[clrLoading]="uploadLoading"
|
[clrLoading]="uploadLoading"
|
||||||
>
|
>
|
||||||
<clr-icon shape="check" size="20"></clr-icon>
|
<clr-icon aria-hidden="true" shape="check" size="20"></clr-icon>
|
||||||
Submit
|
Submit
|
||||||
</button>
|
</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@@ -382,7 +394,11 @@
|
|||||||
|
|
||||||
<ng-container *ngIf="getdataError">
|
<ng-container *ngIf="getdataError">
|
||||||
<span>
|
<span>
|
||||||
<clr-icon shape="error-standard" class="error-icon"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="error-standard"
|
||||||
|
class="error-icon"
|
||||||
|
></clr-icon>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
@@ -430,7 +446,7 @@
|
|||||||
!restrictions.restrictAddRow ? addRecordButtonClick() : ''
|
!restrictions.restrictAddRow ? addRecordButtonClick() : ''
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<clr-icon shape="plus" size="16"></clr-icon>
|
<clr-icon aria-hidden="true" shape="plus" size="16"></clr-icon>
|
||||||
Add Record
|
Add Record
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -686,7 +702,7 @@
|
|||||||
</div> -->
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<div class="modal z-index-highest" *ngIf="nullVariables">
|
<div class="modal z-index-highest" *ngIf="nullVariables">
|
||||||
<div class="modal-dialog" role="dialog" aria-hidden="true">
|
<div class="modal-dialog" role="dialog" aria-hidden="true">
|
||||||
@@ -873,3 +889,17 @@
|
|||||||
</app-dataset-info>
|
</app-dataset-info>
|
||||||
|
|
||||||
<app-viewboxes [(viewboxModal)]="viewboxes"></app-viewboxes>
|
<app-viewboxes [(viewboxModal)]="viewboxes"></app-viewboxes>
|
||||||
|
|
||||||
|
<app-confirm-modal
|
||||||
|
[open]="confirmModal.open"
|
||||||
|
[title]="confirmModal.title"
|
||||||
|
[message]="confirmModal.message"
|
||||||
|
(result)="onConfirmModalResult($event)"
|
||||||
|
></app-confirm-modal>
|
||||||
|
|
||||||
|
<app-bulk-validation-modal
|
||||||
|
[open]="bulkValidation.active"
|
||||||
|
[done]="bulkValidation.done"
|
||||||
|
[total]="bulkValidation.total"
|
||||||
|
(cancel)="cancelBulkValidation({ revert: true })"
|
||||||
|
></app-bulk-validation-modal>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import Handsontable from 'handsontable'
|
import Handsontable from 'handsontable'
|
||||||
import { Subject, Subscription } from 'rxjs'
|
import { Subject, Subscription } from 'rxjs'
|
||||||
|
import { sanitiseForSas } from '../shared/utils/sanitise'
|
||||||
import { SasStoreService } from '../services/sas-store.service'
|
import { SasStoreService } from '../services/sas-store.service'
|
||||||
|
|
||||||
type AOA = any[][]
|
type AOA = any[][]
|
||||||
@@ -25,6 +26,7 @@ import { CellValidationSource } from '../models/CellValidationSource'
|
|||||||
import { FileUploader } from '../models/FileUploader.class'
|
import { FileUploader } from '../models/FileUploader.class'
|
||||||
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||||
import { HotTableInterface } from '../models/HotTable.interface'
|
import { HotTableInterface } from '../models/HotTable.interface'
|
||||||
|
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
|
||||||
import {
|
import {
|
||||||
$DataFormats,
|
$DataFormats,
|
||||||
DSMeta,
|
DSMeta,
|
||||||
@@ -43,6 +45,8 @@ import { Col } from '../shared/dc-validator/models/col.model'
|
|||||||
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
|
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
|
||||||
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
|
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
|
||||||
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
|
||||||
|
import { excelRound } from '../shared/dc-validator/utils/excelRound'
|
||||||
|
import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
|
||||||
import { globals } from '../_globals'
|
import { globals } from '../_globals'
|
||||||
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
|
import { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
|
||||||
import { DynamicExtendedCellValidation } from './models/dynamicExtendedCellValidation'
|
import { DynamicExtendedCellValidation } from './models/dynamicExtendedCellValidation'
|
||||||
@@ -55,8 +59,6 @@ import {
|
|||||||
spinnerRenderer
|
spinnerRenderer
|
||||||
} from './utils/renderers.utils'
|
} from './utils/renderers.utils'
|
||||||
import { LicenceService } from '../services/licence.service'
|
import { LicenceService } from '../services/licence.service'
|
||||||
import * as numbro from 'numbro'
|
|
||||||
import * as languages from 'numbro/dist/languages.min'
|
|
||||||
import { FileUploadEncoding } from '../models/FileUploadEncoding'
|
import { FileUploadEncoding } from '../models/FileUploadEncoding'
|
||||||
import { SpreadsheetService } from '../services/spreadsheet.service'
|
import { SpreadsheetService } from '../services/spreadsheet.service'
|
||||||
import { UploadFileResponse } from '../models/UploadFile'
|
import { UploadFileResponse } from '../models/UploadFile'
|
||||||
@@ -131,7 +133,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
licenseKey: this.hotTable.licenseKey,
|
licenseKey: this.hotTable.licenseKey,
|
||||||
readOnly: this.hotTable.readOnly,
|
readOnly: this.hotTable.readOnly,
|
||||||
copyPaste: this.hotTable.copyPaste,
|
copyPaste: this.hotTable.copyPaste,
|
||||||
contextMenu: true
|
contextMenu: true,
|
||||||
|
className: 'htDark',
|
||||||
|
theme: 'ht-theme-classic'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +159,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
hidden() {
|
hidden() {
|
||||||
const hot: Handsontable.Core = this
|
const hot: Handsontable.Core = this
|
||||||
|
|
||||||
|
// Hide editing actions in read-only (view) mode.
|
||||||
|
if (hot.getSettings().readOnly) return true
|
||||||
|
|
||||||
const fullCellRange: CellRange[] | undefined =
|
const fullCellRange: CellRange[] | undefined =
|
||||||
hot.getSelectedRange()
|
hot.getSelectedRange()
|
||||||
|
|
||||||
@@ -178,6 +185,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
row_above: {
|
row_above: {
|
||||||
name: 'Insert Row above',
|
name: 'Insert Row above',
|
||||||
|
hidden: () => this.hotTable.readOnly === true,
|
||||||
callback: (
|
callback: (
|
||||||
key: string,
|
key: string,
|
||||||
selection: any[],
|
selection: any[],
|
||||||
@@ -191,6 +199,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
row_below: {
|
row_below: {
|
||||||
name: 'Insert Row below',
|
name: 'Insert Row below',
|
||||||
|
hidden: () => this.hotTable.readOnly === true,
|
||||||
callback: (
|
callback: (
|
||||||
key: string,
|
key: string,
|
||||||
selection: any[],
|
selection: any[],
|
||||||
@@ -203,7 +212,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
remove_row: {
|
remove_row: {
|
||||||
name: 'Ignore row'
|
name: 'Ignore row',
|
||||||
|
hidden: () => this.hotTable.readOnly === true
|
||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
name: 'Copy without headers'
|
name: 'Copy without headers'
|
||||||
@@ -214,14 +224,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
copy_column_headers_only: {
|
copy_column_headers_only: {
|
||||||
name: 'Copy headers only'
|
name: 'Copy headers only'
|
||||||
},
|
},
|
||||||
|
// Client-side export of the current grid view (or selection).
|
||||||
|
// skipLeadingCols=1 drops the `Delete?` housekeeping column (col 0).
|
||||||
|
export_file: buildExportMenuItem(() => this.libds || 'export', 1),
|
||||||
sp1: {
|
sp1: {
|
||||||
name: '---------'
|
name: '---------',
|
||||||
|
hidden: () => this.hotTable.readOnly === true
|
||||||
},
|
},
|
||||||
undo: {
|
undo: {
|
||||||
name: 'Undo'
|
name: 'Undo',
|
||||||
|
hidden: () => this.hotTable.readOnly === true
|
||||||
},
|
},
|
||||||
redo: {
|
redo: {
|
||||||
name: 'Redo'
|
name: 'Redo',
|
||||||
|
hidden: () => this.hotTable.readOnly === true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,6 +279,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
public badEdit = false
|
public badEdit = false
|
||||||
public badEditCause: string | undefined
|
public badEditCause: string | undefined
|
||||||
public badEditTitle: string | undefined
|
public badEditTitle: string | undefined
|
||||||
|
get embed() {
|
||||||
|
return globals.embed
|
||||||
|
}
|
||||||
public tableTrue: boolean | undefined
|
public tableTrue: boolean | undefined
|
||||||
public saveLoading = false
|
public saveLoading = false
|
||||||
public approvers: string[] = []
|
public approvers: string[] = []
|
||||||
@@ -352,11 +371,36 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* Hash/values table used for dynamic cell validation
|
* Hash/values table used for dynamic cell validation
|
||||||
*/
|
*/
|
||||||
public cellValidationSource: CellValidationSource[] = []
|
public cellValidationSource: CellValidationSource[] = []
|
||||||
public validationTableLimit = 20
|
public validationTableLimit = 100
|
||||||
|
|
||||||
|
// Incremented on cancel/edit-exit so in-flight dynamic-validation
|
||||||
|
// responses can detect they should drop their post-response work.
|
||||||
|
private validationEpoch = 0
|
||||||
|
// Cells currently showing the loading spinner renderer (keyed `r,c`),
|
||||||
|
// so cancelBulkValidation can reset them.
|
||||||
|
private pendingSpinnerCells = new Set<string>()
|
||||||
|
|
||||||
|
// State for the bulk-validation progress banner (paste / autofill).
|
||||||
|
public bulkValidation: {
|
||||||
|
active: boolean
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
} = { active: false, done: 0, total: 0 }
|
||||||
|
|
||||||
|
// Confirm-modal state used to gate large paste validations.
|
||||||
|
public confirmModal: {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
} = { open: false, title: '', message: '' }
|
||||||
|
private confirmModalResolver: ((v: boolean) => void) | null = null
|
||||||
|
// Index maps into the positional arrays returned by getdynamiccolvals.
|
||||||
|
// The DISPLAY_VALUE column was dropped from the payload (it was never consumed
|
||||||
|
// here — the dropdown source uses RAW_VALUE and rows are matched via
|
||||||
|
// DISPLAY_INDEX), so these indices are contiguous.
|
||||||
public extendedCellValidationFields: {
|
public extendedCellValidationFields: {
|
||||||
DISPLAY_INDEX: number
|
DISPLAY_INDEX: number
|
||||||
EXTRA_COL_NAME: number
|
EXTRA_COL_NAME: number
|
||||||
DISPLAY_VALUE: number
|
|
||||||
DISPLAY_TYPE: number
|
DISPLAY_TYPE: number
|
||||||
RAW_VALUE_NUM: number
|
RAW_VALUE_NUM: number
|
||||||
RAW_VALUE_CHAR: number
|
RAW_VALUE_CHAR: number
|
||||||
@@ -364,18 +408,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
} = {
|
} = {
|
||||||
DISPLAY_INDEX: 0,
|
DISPLAY_INDEX: 0,
|
||||||
EXTRA_COL_NAME: 1,
|
EXTRA_COL_NAME: 1,
|
||||||
DISPLAY_VALUE: 2,
|
DISPLAY_TYPE: 2,
|
||||||
DISPLAY_TYPE: 3,
|
RAW_VALUE_NUM: 3,
|
||||||
RAW_VALUE_NUM: 4,
|
RAW_VALUE_CHAR: 4,
|
||||||
RAW_VALUE_CHAR: 5,
|
FORCE_FLAG: 5
|
||||||
FORCE_FLAG: 6
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public cellValidationFields: {
|
public cellValidationFields: {
|
||||||
DISPLAY_INDEX: number
|
DISPLAY_INDEX: number
|
||||||
DISPLAY_VALUE: number
|
|
||||||
RAW_VALUE: number
|
RAW_VALUE: number
|
||||||
} = { DISPLAY_INDEX: 0, DISPLAY_VALUE: 1, RAW_VALUE: 2 }
|
} = { DISPLAY_INDEX: 0, RAW_VALUE: 1 }
|
||||||
|
|
||||||
public disabledBasicDynamicCellValidationMap: {
|
public disabledBasicDynamicCellValidationMap: {
|
||||||
row: number
|
row: number
|
||||||
@@ -400,10 +442,6 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
private cdf: ChangeDetectorRef,
|
private cdf: ChangeDetectorRef,
|
||||||
private spreadsheetService: SpreadsheetService
|
private spreadsheetService: SpreadsheetService
|
||||||
) {
|
) {
|
||||||
const lang = languages[window.navigator.language]
|
|
||||||
if (lang)
|
|
||||||
numbro.default.registerLanguage(languages[window.navigator.language])
|
|
||||||
|
|
||||||
this.parseRestrictions()
|
this.parseRestrictions()
|
||||||
this.setRestrictions()
|
this.setRestrictions()
|
||||||
}
|
}
|
||||||
@@ -958,7 +996,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelEdit() {
|
cancelEdit() {
|
||||||
this.toggleHotPlugin('contextMenu', false)
|
this.cancelBulkValidation({ revert: false })
|
||||||
|
|
||||||
|
// Keep the context menu enabled after leaving edit mode (view mode keeps
|
||||||
|
// Copy/Export; editing items hide themselves when read-only).
|
||||||
|
this.toggleHotPlugin('contextMenu', true)
|
||||||
|
|
||||||
this.cellValidationSource = []
|
this.cellValidationSource = []
|
||||||
|
|
||||||
@@ -987,7 +1029,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
hot.validateRows(this.modifedRowsIndexes)
|
this.modifedRowsIndexes = []
|
||||||
|
hot.validateCells()
|
||||||
// this.editRecordListeners();
|
// this.editRecordListeners();
|
||||||
for (const sortConfig of sortConfigs) {
|
for (const sortConfig of sortConfigs) {
|
||||||
columnSorting.sort(sortConfig)
|
columnSorting.sort(sortConfig)
|
||||||
@@ -996,6 +1039,160 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.checkRowLimit()
|
this.checkRowLimit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the bulk-validation flow (paste / autofill): invalidate in-flight
|
||||||
|
* responses via the epoch counter, reset spinner cells, hide the banner,
|
||||||
|
* and (when triggered from the banner's Cancel button) undo the change.
|
||||||
|
*/
|
||||||
|
public cancelBulkValidation(opts: { revert?: boolean } = {}) {
|
||||||
|
const wasActive = this.bulkValidation.active
|
||||||
|
|
||||||
|
// Invalidate any in-flight dynamicCellValidation responses. Note:
|
||||||
|
// sasService.request has no abort signal, so the network request itself
|
||||||
|
// keeps running — we only drop the response handling.
|
||||||
|
this.validationEpoch++
|
||||||
|
|
||||||
|
// Reset any cells still showing the loading spinner renderer.
|
||||||
|
const hot = this.hotInstance
|
||||||
|
if (hot && this.pendingSpinnerCells.size > 0) {
|
||||||
|
for (const key of this.pendingSpinnerCells) {
|
||||||
|
const [rStr, cStr] = key.split(',')
|
||||||
|
const r = Number(rStr)
|
||||||
|
const c = Number(cStr)
|
||||||
|
hot.setCellMeta(r, c, 'renderer', noSpinnerRenderer)
|
||||||
|
}
|
||||||
|
this.pendingSpinnerCells.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop placeholder entries (values still empty — request was cancelled).
|
||||||
|
this.cellValidationSource = this.cellValidationSource.filter(
|
||||||
|
(entry) => !entry.pending || entry.values.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
this.bulkValidation = {
|
||||||
|
active: false,
|
||||||
|
done: 0,
|
||||||
|
total: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasActive && opts.revert && hot) {
|
||||||
|
this.undoLastChange(hot)
|
||||||
|
}
|
||||||
|
if (hot) hot.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
private undoLastChange(hot: Handsontable): void {
|
||||||
|
const plugin = hot.getPlugin('undoRedo') as unknown as
|
||||||
|
| { isUndoAvailable(): boolean; undo(): void }
|
||||||
|
| undefined
|
||||||
|
if (plugin?.isUndoAvailable()) plugin.undo()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive dynamic-source load + validation for cells that were bulk-filled
|
||||||
|
* (paste or autofill). HARDSELECT_HOOK / SOFTSELECT_HOOK columns need a SAS
|
||||||
|
* roundtrip; non-hook cells just need HOT's static validators. Caps backend
|
||||||
|
* concurrency, uses an epoch so a cancelled run can't mutate later state,
|
||||||
|
* and gates >3-cell runs behind a confirm modal.
|
||||||
|
*/
|
||||||
|
private async runBulkValidation(
|
||||||
|
hot: Handsontable,
|
||||||
|
ranges: Array<{
|
||||||
|
startRow: number
|
||||||
|
startCol: number
|
||||||
|
endRow: number
|
||||||
|
endCol: number
|
||||||
|
}>,
|
||||||
|
source: 'paste' | 'autofill'
|
||||||
|
): Promise<void> {
|
||||||
|
const rows = new Set<number>()
|
||||||
|
const hookTargets: Array<{ r: number; c: number }> = []
|
||||||
|
const hookCols = new Set<string>()
|
||||||
|
for (const range of ranges) {
|
||||||
|
for (let r = range.startRow; r <= range.endRow; r++) {
|
||||||
|
for (let c = range.startCol; c <= range.endCol; c++) {
|
||||||
|
rows.add(r)
|
||||||
|
const colKey = hot.colToProp(c) as string
|
||||||
|
if (this.dcValidator?.hasDqRules(colKey, ['HARDSELECT_HOOK'])) {
|
||||||
|
hookCols.add(colKey)
|
||||||
|
hookTargets.push({ r, c })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No hook columns → HOT's own setDataAtCell → validateChanges pass
|
||||||
|
// (triggered by populateFromArray) already validates against the new
|
||||||
|
// value and paints htInvalid. A second validateRows here would race:
|
||||||
|
// it runs sync inside afterPaste/afterAutofill BEFORE applyChanges
|
||||||
|
// writes the data, captures the stale old value, and overwrites
|
||||||
|
// cellProperties.valid back to true — causing a 1-action lag.
|
||||||
|
if (hookTargets.length === 0) return
|
||||||
|
|
||||||
|
if (hookTargets.length === 1) {
|
||||||
|
const { r, c } = hookTargets[0]
|
||||||
|
await this.dynamicCellValidation(r, c)
|
||||||
|
hot.validateRows([...rows], () => hot.render())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hookTargets.length > 3) {
|
||||||
|
const colsList = [...hookCols].join(', ')
|
||||||
|
const ok = await this.showConfirmModal(
|
||||||
|
`Confirm ${source} validation`,
|
||||||
|
`You are about to trigger ${hookTargets.length} backend SAS request(s) for columns: ${colsList}. Do you wish to proceed?`
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
this.undoLastChange(hot)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const epoch = this.validationEpoch
|
||||||
|
this.bulkValidation = {
|
||||||
|
active: true,
|
||||||
|
done: 0,
|
||||||
|
total: hookTargets.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONCURRENCY = 2
|
||||||
|
let idx = 0
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: CONCURRENCY }, async () => {
|
||||||
|
while (idx < hookTargets.length) {
|
||||||
|
if (epoch !== this.validationEpoch) return
|
||||||
|
const { r, c } = hookTargets[idx++]
|
||||||
|
await this.dynamicCellValidation(r, c, { skipRender: true })
|
||||||
|
if (epoch === this.validationEpoch) {
|
||||||
|
this.bulkValidation.done++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (epoch !== this.validationEpoch) return
|
||||||
|
|
||||||
|
this.bulkValidation = {
|
||||||
|
...this.bulkValidation,
|
||||||
|
active: false
|
||||||
|
}
|
||||||
|
hot.validateRows([...rows], () => hot.render())
|
||||||
|
}
|
||||||
|
|
||||||
|
private showConfirmModal(title: string, message: string): Promise<boolean> {
|
||||||
|
this.confirmModal = { open: true, title, message }
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
this.confirmModalResolver = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public onConfirmModalResult(value: boolean) {
|
||||||
|
const resolver = this.confirmModalResolver
|
||||||
|
this.confirmModalResolver = null
|
||||||
|
this.confirmModal = { ...this.confirmModal, open: false }
|
||||||
|
if (resolver) resolver(value)
|
||||||
|
}
|
||||||
|
|
||||||
timesClicked = 0
|
timesClicked = 0
|
||||||
public hotClicked() {
|
public hotClicked() {
|
||||||
if (this.timesClicked === 1 && this.hotTable.readOnly) {
|
if (this.timesClicked === 1 && this.hotTable.readOnly) {
|
||||||
@@ -1045,12 +1242,16 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new empty row object with proper structure
|
* Creates a new empty row object with proper structure.
|
||||||
|
* Columns with NOTNULL DQ rules are pre-populated with their RULE_VALUE.
|
||||||
*/
|
*/
|
||||||
private createEmptyRow(): any {
|
private createEmptyRow(): any {
|
||||||
const newRow: any = {}
|
const newRow: any = {}
|
||||||
this.headerColumns.forEach((col: string) => {
|
this.cellValidation.forEach((rule: any) => {
|
||||||
newRow[col] = ''
|
const dataKey = rule.data
|
||||||
|
newRow[dataKey] = this.hotDataSchema.hasOwnProperty(dataKey)
|
||||||
|
? this.hotDataSchema[dataKey]
|
||||||
|
: ''
|
||||||
})
|
})
|
||||||
newRow['noLinkOption'] = true
|
newRow['noLinkOption'] = true
|
||||||
return newRow
|
return newRow
|
||||||
@@ -1661,7 +1862,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.submit = true
|
this.submit = true
|
||||||
const updateParams: any = {}
|
const updateParams: any = {}
|
||||||
updateParams.ACTION = 'LOAD'
|
updateParams.ACTION = 'LOAD'
|
||||||
this.message = this.message.replace(/\n/g, '. ')
|
this.message = sanitiseForSas(this.message.replace(/\n/g, '. '))
|
||||||
updateParams.MESSAGE = this.message
|
updateParams.MESSAGE = this.message
|
||||||
// updateParams.APPROVER = this.approver;
|
// updateParams.APPROVER = this.approver;
|
||||||
updateParams.LIBDS = this.libds
|
updateParams.LIBDS = this.libds
|
||||||
@@ -1960,18 +2161,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* @param row handsontable row
|
* @param row handsontable row
|
||||||
* @param column handsontable column
|
* @param column handsontable column
|
||||||
*/
|
*/
|
||||||
public dynamicCellValidation(row: number, column: number) {
|
public async dynamicCellValidation(
|
||||||
if (this.dynamicCellValidationDisabled(row, column)) return
|
row: number,
|
||||||
|
column: number,
|
||||||
|
opts?: { skipRender?: boolean },
|
||||||
|
retried = false
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.dynamicCellValidationDisabled(row, column))
|
||||||
|
return Promise.resolve()
|
||||||
|
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
|
|
||||||
const cellMeta = hot.getCellMeta(row, column)
|
const cellMeta = hot.getCellMeta(row, column)
|
||||||
|
|
||||||
if (cellMeta.readOnly) return
|
if (cellMeta.readOnly) return Promise.resolve()
|
||||||
|
|
||||||
const cellData = hot.getDataAtCell(row, column)
|
const cellData = hot.getDataAtCell(row, column)
|
||||||
const clickedRow = this.helperService.deepClone(this.dataSource[row])
|
const clickedRow = this.helperService.deepClone(this.dataSource[row])
|
||||||
const clickedColumnKey = Object.keys(clickedRow)[column]
|
const clickedColumnKey = Object.keys(clickedRow)[column]
|
||||||
|
const skipRender = !!opts?.skipRender
|
||||||
|
const myEpoch = this.validationEpoch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We will hash the row (without current column) so later we check if hash is the same
|
* We will hash the row (without current column) so later we check if hash is the same
|
||||||
@@ -1992,6 +2201,22 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
* Set the values for found hash.
|
* Set the values for found hash.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
if (validationSourceIndex > -1) {
|
||||||
|
// In-flight dedup: another call with the same hash is mid-request.
|
||||||
|
// Wait for it then re-enter once so we walk the populated cache-hit
|
||||||
|
// path instead of validating against the empty placeholder.
|
||||||
|
const inFlight = this.cellValidationSource[validationSourceIndex].pending
|
||||||
|
if (inFlight && !retried) {
|
||||||
|
try {
|
||||||
|
await inFlight
|
||||||
|
} catch {
|
||||||
|
/* swallowed — original caller handles */
|
||||||
|
}
|
||||||
|
if (myEpoch !== this.validationEpoch) return
|
||||||
|
return this.dynamicCellValidation(row, column, opts, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (validationSourceIndex > -1) {
|
if (validationSourceIndex > -1) {
|
||||||
let colSource = this.cellValidationSource[
|
let colSource = this.cellValidationSource[
|
||||||
validationSourceIndex
|
validationSourceIndex
|
||||||
@@ -2067,14 +2292,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
cellHadSource && cellHasValue
|
cellHadSource && cellHasValue
|
||||||
)
|
)
|
||||||
|
|
||||||
hot.render()
|
if (!skipRender) hot.render()
|
||||||
})
|
})
|
||||||
}
|
} else if (validationSourceIndex < 0) {
|
||||||
|
/**
|
||||||
/**
|
* Send request to sas.
|
||||||
* Send request to sas.
|
*/
|
||||||
*/
|
|
||||||
if (validationSourceIndex < 0) {
|
|
||||||
const data = {
|
const data = {
|
||||||
SASControlTable: [
|
SASControlTable: [
|
||||||
{
|
{
|
||||||
@@ -2106,21 +2329,53 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
count: this.cellValidationSource.length + 1
|
count: this.cellValidationSource.length + 1
|
||||||
})
|
})
|
||||||
|
|
||||||
hot.setCellMeta(row, column, 'renderer', spinnerRenderer)
|
|
||||||
|
|
||||||
this.currentEditRecordLoadings.push(column)
|
this.currentEditRecordLoadings.push(column)
|
||||||
hot.render()
|
|
||||||
|
|
||||||
this.sasService
|
const spinnerKey = `${row},${column}`
|
||||||
|
|
||||||
|
// Defer the spinner renderer so the click event finishes settling
|
||||||
|
// HOT's focus catcher before we replace td.innerHTML. Without this
|
||||||
|
// defer, clicking the cell loses focus immediately.
|
||||||
|
// Skip the spinner entirely if SAS responds before this fires —
|
||||||
|
// avoids the brief flicker users were seeing on fast responses.
|
||||||
|
// Also skip during paste (skipRender) — the progress banner handles
|
||||||
|
// visual feedback and cell spinners would just flicker.
|
||||||
|
const spinnerTimeout: ReturnType<typeof setTimeout> | null = skipRender
|
||||||
|
? null
|
||||||
|
: setTimeout(() => {
|
||||||
|
hot.setCellMeta(row, column, 'renderer', spinnerRenderer)
|
||||||
|
this.pendingSpinnerCells.add(spinnerKey)
|
||||||
|
hot.render()
|
||||||
|
}, 150)
|
||||||
|
|
||||||
|
const pendingPromise = this.sasService
|
||||||
.request('editors/getdynamiccolvals', data, undefined, {
|
.request('editors/getdynamiccolvals', data, undefined, {
|
||||||
suppressSuccessAbortModal: true,
|
suppressSuccessAbortModal: true,
|
||||||
suppressErrorAbortModal: true
|
suppressErrorAbortModal: true
|
||||||
})
|
})
|
||||||
.then((res: RequestWrapperResponse) => {
|
.then(async (res: RequestWrapperResponse) => {
|
||||||
|
if (spinnerTimeout) clearTimeout(spinnerTimeout)
|
||||||
|
this.pendingSpinnerCells.delete(spinnerKey)
|
||||||
|
|
||||||
|
// Cancelled mid-flight — drop the placeholder entry so future
|
||||||
|
// calls don't see an empty cache hit, and skip all UI work.
|
||||||
|
if (myEpoch !== this.validationEpoch) {
|
||||||
|
const idx = this.cellValidationSource.findIndex(
|
||||||
|
(e) => e.hash === hashedRow
|
||||||
|
)
|
||||||
|
if (idx > -1) this.cellValidationSource.splice(idx, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const colSource = res.adapterResponse.dynamic_values.map(
|
const colSource = res.adapterResponse.dynamic_values.map(
|
||||||
(el: any) => el[this.cellValidationFields.RAW_VALUE]
|
(el: any) => el[this.cellValidationFields.RAW_VALUE]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this.currentEditRecordLoadings.splice(
|
||||||
|
this.currentEditRecordLoadings.indexOf(column),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
if (colSource.length > 0) {
|
if (colSource.length > 0) {
|
||||||
const validationSourceIndex = this.cellValidationSource.findIndex(
|
const validationSourceIndex = this.cellValidationSource.findIndex(
|
||||||
(entry: CellValidationSource) => entry.hash === hashedRow
|
(entry: CellValidationSource) => entry.hash === hashedRow
|
||||||
@@ -2132,49 +2387,37 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
row: row,
|
row: row,
|
||||||
col: column,
|
col: column,
|
||||||
values: res.adapterResponse.dynamic_values,
|
values: res.adapterResponse.dynamic_values,
|
||||||
extended_values: res.adapterResponse.dynamic_extended_values
|
extended_values: res.adapterResponse.dynamic_extended_values,
|
||||||
|
pending: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Removing the spinner from cell, so validation not fail
|
|
||||||
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
|
|
||||||
this.currentEditRecordLoadings.splice(
|
|
||||||
this.currentEditRecordLoadings.indexOf(column),
|
|
||||||
1
|
|
||||||
)
|
|
||||||
hot.deselectCell()
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `cells` function of hot settings is remembering the old state of component
|
* In the case that the original value is not included in the newly created cell dropdown
|
||||||
* we need to update it here after we set new `cellValidationSource` (validation lookup hash table) values
|
* and validation type is HARDSELECT, the cell shoud be red
|
||||||
* so that it will check those values to decide whether numeric cells should be
|
|
||||||
* converted to the dropdown
|
|
||||||
*/
|
*/
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
hot.batch(() => {
|
|
||||||
/**
|
|
||||||
* In the case that the original value is not included in the newly created cell dropdown
|
|
||||||
* and validation type is HARDSELECT, the cell shoud be red
|
|
||||||
*/
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.reSetCellValidationValues(true, row)
|
this.reSetCellValidationValues(true, row)
|
||||||
hot.render()
|
|
||||||
|
|
||||||
hot.validateRows([row])
|
if (!skipRender) {
|
||||||
|
hot.render()
|
||||||
|
hot.validateRows([row])
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
}, 100)
|
}, 100)
|
||||||
})
|
)
|
||||||
|
} else {
|
||||||
|
if (!skipRender) {
|
||||||
|
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
|
||||||
|
hot.render()
|
||||||
|
}
|
||||||
|
const idx = this.cellValidationSource.findIndex(
|
||||||
|
(e) => e.hash === hashedRow
|
||||||
|
)
|
||||||
|
if (idx > -1) this.cellValidationSource[idx].pending = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
//Removing the spinner from cell, so validation not fail
|
|
||||||
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
|
|
||||||
this.currentEditRecordLoadings.splice(
|
|
||||||
this.currentEditRecordLoadings.indexOf(column),
|
|
||||||
1
|
|
||||||
)
|
|
||||||
hot.deselectCell()
|
|
||||||
hot.render()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If hash table limit reached, remove the oldest element.
|
* If hash table limit reached, remove the oldest element.
|
||||||
* Oldest element is element with lowest `count` number.
|
* Oldest element is element with lowest `count` number.
|
||||||
@@ -2190,18 +2433,25 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
|
if (spinnerTimeout) clearTimeout(spinnerTimeout)
|
||||||
|
this.pendingSpinnerCells.delete(spinnerKey)
|
||||||
|
|
||||||
const currentRowHashIndex = this.cellValidationSource.findIndex(
|
const currentRowHashIndex = this.cellValidationSource.findIndex(
|
||||||
(x) => x.hash === hashedRow
|
(x) => x.hash === hashedRow
|
||||||
)
|
)
|
||||||
|
|
||||||
this.cellValidationSource.splice(currentRowHashIndex, 1)
|
this.cellValidationSource.splice(currentRowHashIndex, 1)
|
||||||
|
|
||||||
hot.batch(() => {
|
if (myEpoch !== this.validationEpoch) return
|
||||||
// Render error icon inside a cell
|
|
||||||
hot.setCellMeta(row, column, 'renderer', errorRenderer)
|
|
||||||
|
|
||||||
hot.render()
|
if (!skipRender) {
|
||||||
})
|
hot.batch(() => {
|
||||||
|
// Render error icon inside a cell
|
||||||
|
hot.setCellMeta(row, column, 'renderer', errorRenderer)
|
||||||
|
|
||||||
|
hot.render()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
//Stop edit record modal loading spinner
|
//Stop edit record modal loading spinner
|
||||||
this.currentEditRecordLoadings.splice(
|
this.currentEditRecordLoadings.splice(
|
||||||
@@ -2214,8 +2464,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
// After waiting time remove the error icon from cell and edit record modal field
|
// After waiting time remove the error icon from cell and edit record modal field
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
|
if (!skipRender) {
|
||||||
hot.render()
|
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
|
||||||
|
hot.render()
|
||||||
|
}
|
||||||
|
|
||||||
//Remove error icon on the edit record modal field
|
//Remove error icon on the edit record modal field
|
||||||
this.currentEditRecordErrors.splice(
|
this.currentEditRecordErrors.splice(
|
||||||
@@ -2228,8 +2480,19 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
this.loggerService.log('getdynamiccolvals error:', err)
|
this.loggerService.log('getdynamiccolvals error:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const entryIdx = this.cellValidationSource.findIndex(
|
||||||
|
(e) => e.hash === hashedRow
|
||||||
|
)
|
||||||
|
if (entryIdx > -1) {
|
||||||
|
this.cellValidationSource[entryIdx].pending = pendingPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
return pendingPromise
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
checkEmptyRowWhenFilter() {
|
checkEmptyRowWhenFilter() {
|
||||||
@@ -2676,13 +2939,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
// Note: this.headerColumns and this.columnHeader contains same data
|
// Note: this.headerColumns and this.columnHeader contains same data
|
||||||
// need to resolve redundancy
|
// need to resolve redundancy
|
||||||
|
|
||||||
// default schema
|
// default schema - includes NOTNULL defaults from DQ rules
|
||||||
for (let i = 0; i < this.headerColumns.length; i++) {
|
for (let i = 0; i < this.headerColumns.length; i++) {
|
||||||
const colType = this.cellValidation[i].type
|
const colType = this.cellValidation[i].type
|
||||||
|
|
||||||
this.hotDataSchema[this.cellValidation[i].data] = getHotDataSchema(
|
this.hotDataSchema[this.cellValidation[i].data] = getHotDataSchema(
|
||||||
colType,
|
colType,
|
||||||
this.cellValidation[i]
|
this.cellValidation[i],
|
||||||
|
this.dcValidator?.getDqDetails()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2858,7 +3122,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
)
|
)
|
||||||
|
|
||||||
this.hotTable.hidden = false
|
this.hotTable.hidden = false
|
||||||
this.toggleHotPlugin('contextMenu', false)
|
// Keep the context menu enabled in view mode too so Copy/Export remain
|
||||||
|
// available; editing items hide themselves when read-only.
|
||||||
|
this.toggleHotPlugin('contextMenu', true)
|
||||||
/**
|
/**
|
||||||
* This is needed if freeze column is enabled
|
* This is needed if freeze column is enabled
|
||||||
*/
|
*/
|
||||||
@@ -2928,6 +3194,31 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.setCellFilter(true)
|
this.setCellFilter(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ROUND: round numeric values Excel-style before they are written.
|
||||||
|
// Mutating `changes` in place (rather than setDataAtRowProp) avoids
|
||||||
|
// re-entrancy and uniformly covers edit, paste and autofill.
|
||||||
|
hot.addHook('beforeChange', (changes: any[]) => {
|
||||||
|
if (!changes) return
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
if (!change) continue
|
||||||
|
|
||||||
|
const [, prop, , newValue] = change
|
||||||
|
const colName =
|
||||||
|
typeof prop === 'string'
|
||||||
|
? prop
|
||||||
|
: (hot.colToProp(prop as number) as string)
|
||||||
|
|
||||||
|
const digits = this.dcValidator?.getRoundDigits(colName)
|
||||||
|
if (digits === undefined) continue
|
||||||
|
|
||||||
|
const num = Number(newValue)
|
||||||
|
if (newValue !== null && newValue !== '' && !isNaN(num)) {
|
||||||
|
change[3] = excelRound(num, digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
hot.addHook('afterChange', (source: any, change: any) => {
|
hot.addHook('afterChange', (source: any, change: any) => {
|
||||||
if (change === 'edit') {
|
if (change === 'edit') {
|
||||||
const hot = this.hotInstance
|
const hot = this.hotInstance
|
||||||
@@ -2946,6 +3237,38 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hot.addHook('afterPaste', async (_data: any, coords: any) => {
|
||||||
|
// In read-only mode HOT discards the paste itself, so nothing to validate.
|
||||||
|
if (this.hotTable.readOnly) return
|
||||||
|
const ranges = (coords as any[]).map((r) => ({
|
||||||
|
startRow: r.startRow,
|
||||||
|
startCol: r.startCol,
|
||||||
|
endRow: r.endRow,
|
||||||
|
endCol: r.endCol
|
||||||
|
}))
|
||||||
|
await this.runBulkValidation(hot, ranges, 'paste')
|
||||||
|
})
|
||||||
|
|
||||||
|
hot.addHook(
|
||||||
|
'afterAutofill',
|
||||||
|
async (_fillData: any, _sourceRange: any, targetRange: any) => {
|
||||||
|
if (this.hotTable.readOnly) return
|
||||||
|
const { from, to } = targetRange
|
||||||
|
await this.runBulkValidation(
|
||||||
|
hot,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
startRow: Math.min(from.row, to.row),
|
||||||
|
startCol: Math.min(from.col, to.col),
|
||||||
|
endRow: Math.max(from.row, to.row),
|
||||||
|
endCol: Math.max(from.col, to.col)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'autofill'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
hot.addHook('afterRender', (isForced: boolean) => {
|
hot.addHook('afterRender', (isForced: boolean) => {
|
||||||
this.eventService.dispatchEvent('resize')
|
this.eventService.dispatchEvent('resize')
|
||||||
|
|
||||||
@@ -2987,21 +3310,62 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
hot.addHook('beforePaste', (data: any, cords: any) => {
|
// Auto-populate NOTNULL default when validation fails due to empty value
|
||||||
const startCol = cords[0].startCol
|
hot.addHook(
|
||||||
|
'afterValidate',
|
||||||
|
(isValid: boolean, value: any, row: number, prop: string | number) => {
|
||||||
|
if (isValid || !isEmpty(value)) return
|
||||||
|
|
||||||
// We iterate trough pasting data to convert to numbers if needed
|
const colName =
|
||||||
data[0] = data[0].map((value: any, index: number) => {
|
typeof prop === 'string'
|
||||||
|
? prop
|
||||||
|
: (hot.colToProp(prop as number) as string)
|
||||||
|
|
||||||
|
const defaultValue = this.dcValidator?.getNotNullDefaultValue(colName)
|
||||||
|
if (defaultValue === undefined) return
|
||||||
|
|
||||||
|
// Auto-populate using setTimeout to avoid modifying during validation
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isEmpty(hot.getDataAtRowProp(row, colName))) {
|
||||||
|
hot.setDataAtRowProp(row, colName, defaultValue, 'autoPopulate')
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Coerce numeric-column values from string → number so length-check
|
||||||
|
// and downstream validators see the correct type on the first pass
|
||||||
|
// (string "3.5" passes `Number(value) === value`, masking float-in-
|
||||||
|
// short-num errors until the next edit).
|
||||||
|
const coerceNumericRow = (
|
||||||
|
row: any[],
|
||||||
|
startCol: number,
|
||||||
|
rowMaxLen?: number
|
||||||
|
): any[] =>
|
||||||
|
row.map((value: any, index: number) => {
|
||||||
|
if (rowMaxLen !== undefined && index >= rowMaxLen) return value
|
||||||
const colName = this.columnHeader[startCol + index]
|
const colName = this.columnHeader[startCol + index]
|
||||||
const isColNum = this.$dataFormats?.vars[colName]?.type === 'num'
|
const isColNum = this.$dataFormats?.vars[colName]?.type === 'num'
|
||||||
const specialMissing = isSpecialMissing(value)
|
const specialMissing = isSpecialMissing(value)
|
||||||
|
|
||||||
if (isColNum && !isNaN(value) && !specialMissing) value = value * 1
|
if (isColNum && !isNaN(value) && !specialMissing) value = value * 1
|
||||||
|
|
||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hot.addHook('beforePaste', (data: any, cords: any) => {
|
||||||
|
const startCol = cords[0].startCol
|
||||||
|
for (let r = 0; r < data.length; r++) {
|
||||||
|
data[r] = coerceNumericRow(data[r], startCol)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
hot.addHook(
|
||||||
|
'beforeAutofill',
|
||||||
|
(selectionData: any[][], sourceRange: any) => {
|
||||||
|
const startCol = sourceRange.from.col
|
||||||
|
return selectionData.map((row) => coerceNumericRow(row, startCol))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
hot.addHook('afterRemoveRow', () => {
|
hot.addHook('afterRemoveRow', () => {
|
||||||
this.checkRowLimit()
|
this.checkRowLimit()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
export interface DynamicExtendedCellValidation {
|
export interface DynamicExtendedCellValidation {
|
||||||
DISPLAY_INDEX: number
|
DISPLAY_INDEX: number
|
||||||
DISPLAY_TYPE: string
|
DISPLAY_TYPE: string
|
||||||
DISPLAY_VALUE: string
|
|
||||||
EXTRA_COL_NAME: string
|
EXTRA_COL_NAME: string
|
||||||
RAW_VALUE_CHAR: string
|
RAW_VALUE_CHAR: string
|
||||||
RAW_VALUE_NUM: number
|
RAW_VALUE_NUM: number
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { makeNumberFormatRenderer } from './renderers.utils'
|
||||||
|
|
||||||
|
describe('makeNumberFormatRenderer', () => {
|
||||||
|
it('renders a numeric cell as EUR currency without changing the value', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [{ amt: 1025 }],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'amt',
|
||||||
|
type: 'numeric',
|
||||||
|
renderer: makeNumberFormatRenderer(
|
||||||
|
'{"style":"currency","currency":"EUR"}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
// Display is formatted as currency...
|
||||||
|
expect(td?.textContent).toContain('€')
|
||||||
|
expect(td?.textContent).toContain('1,025')
|
||||||
|
// ...but the stored value is untouched
|
||||||
|
expect(hot.getDataAtCell(0, 0)).toEqual(1025)
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is overridden by numbro numericFormat (why DcValidator clears it for NUMBER_FORMAT cols)', () => {
|
||||||
|
// Regression note: on a `type: 'numeric'` column, a `numericFormat` makes
|
||||||
|
// HOT re-render via numbro and drop our currency symbol. DcValidator clears
|
||||||
|
// numericFormat on NUMBER_FORMAT columns so the Intl renderer wins.
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [{ amt: 1025 }],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'amt',
|
||||||
|
type: 'numeric',
|
||||||
|
numericFormat: { pattern: '0,0', culture: 'en-US' },
|
||||||
|
renderer: makeNumberFormatRenderer(
|
||||||
|
'{"style":"currency","currency":"EUR"}'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.textContent).not.toContain('€')
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a plain number when options JSON is invalid', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
|
||||||
|
const hot = new Handsontable(container, {
|
||||||
|
data: [{ amt: 1025 }],
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
data: 'amt',
|
||||||
|
type: 'numeric',
|
||||||
|
renderer: makeNumberFormatRenderer('not json')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
licenseKey: 'non-commercial-and-evaluation'
|
||||||
|
})
|
||||||
|
hot.render()
|
||||||
|
|
||||||
|
const td = hot.getCell(0, 0)
|
||||||
|
expect(td?.textContent).not.toContain('€')
|
||||||
|
|
||||||
|
hot.destroy()
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,57 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a display-only HOT renderer that formats numeric cell values using
|
||||||
|
* Intl.NumberFormat. The stored/submitted value is never changed — only the
|
||||||
|
* rendered text. `ruleValue` is a JSON string of Intl.NumberFormat options
|
||||||
|
* (e.g. '{"style":"currency","currency":"EUR","minimumFractionDigits":2}').
|
||||||
|
*
|
||||||
|
* Falls back to the plain text renderer when the JSON is invalid, the options
|
||||||
|
* are rejected by Intl.NumberFormat, or the value is not a finite number.
|
||||||
|
*/
|
||||||
|
export const makeNumberFormatRenderer = (ruleValue?: string) => {
|
||||||
|
let formatter: Intl.NumberFormat | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = ruleValue ? JSON.parse(ruleValue) : {}
|
||||||
|
formatter = new Intl.NumberFormat(window.navigator.language, options)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
`NUMBER_FORMAT - invalid Intl.NumberFormat options: ${ruleValue}`
|
||||||
|
)
|
||||||
|
formatter = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRenderer = Handsontable.renderers.getRenderer('text')
|
||||||
|
|
||||||
|
return (
|
||||||
|
instance: any,
|
||||||
|
td: any,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
prop: string | number,
|
||||||
|
value: any,
|
||||||
|
cellProperties: any
|
||||||
|
) => {
|
||||||
|
// Render via the base text renderer first to preserve cell styling/classes
|
||||||
|
// (readOnly, alignment, etc.), then override the displayed text.
|
||||||
|
baseRenderer(instance, td, row, col, prop, value, cellProperties)
|
||||||
|
|
||||||
|
const num = Number(value)
|
||||||
|
if (
|
||||||
|
formatter &&
|
||||||
|
value !== null &&
|
||||||
|
value !== undefined &&
|
||||||
|
value !== '' &&
|
||||||
|
!isNaN(num)
|
||||||
|
) {
|
||||||
|
td.textContent = formatter.format(num)
|
||||||
|
}
|
||||||
|
|
||||||
|
return td
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom renderer for HOT cell
|
* Custom renderer for HOT cell
|
||||||
* Used to show error icon
|
* Used to show error icon
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const freeTierConfig: LicenceState = {
|
|||||||
lineage_daily_limit: 3,
|
lineage_daily_limit: 3,
|
||||||
tables_in_library_limit: 35,
|
tables_in_library_limit: 35,
|
||||||
viewbox: true,
|
viewbox: true,
|
||||||
fileUpload: true,
|
fileUpload: false,
|
||||||
editRecord: true,
|
editRecord: true,
|
||||||
addRecord: true
|
addRecord: true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="searchLibTreeInput.value.length < 1"
|
*ngIf="searchLibTreeInput.value.length < 1"
|
||||||
shape="search"
|
shape="search"
|
||||||
|
aria-hidden="true"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="searchLibTreeInput.value.length > 0"
|
*ngIf="searchLibTreeInput.value.length > 0"
|
||||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||||
shape="times"
|
shape="times"
|
||||||
|
aria-label="Clear libraries search"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
</div>
|
</div>
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"
|
"
|
||||||
class="m-0 cursor-pointer"
|
class="m-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<clr-icon shape="rack-server"></clr-icon>
|
<clr-icon shape="rack-server" aria-hidden="true"></clr-icon>
|
||||||
{{ library.LIBRARYREF }}
|
{{ library.LIBRARYREF }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -60,6 +62,7 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="searchTreeInput.value.length < 1"
|
*ngIf="searchTreeInput.value.length < 1"
|
||||||
shape="search"
|
shape="search"
|
||||||
|
aria-hidden="true"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="searchTreeInput.value.length > 0"
|
*ngIf="searchTreeInput.value.length > 0"
|
||||||
@@ -69,6 +72,7 @@
|
|||||||
treeOnFilter(library, 'tables')
|
treeOnFilter(library, 'tables')
|
||||||
"
|
"
|
||||||
shape="times"
|
shape="times"
|
||||||
|
aria-label="Clear tables search"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
</div>
|
</div>
|
||||||
</clr-tree-node>
|
</clr-tree-node>
|
||||||
@@ -90,8 +94,16 @@
|
|||||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||||
>
|
>
|
||||||
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
||||||
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon>
|
<clr-icon
|
||||||
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon>
|
*ngSwitchCase="true"
|
||||||
|
shape="bolt"
|
||||||
|
aria-hidden="true"
|
||||||
|
></clr-icon>
|
||||||
|
<clr-icon
|
||||||
|
*ngSwitchCase="false"
|
||||||
|
shape="table"
|
||||||
|
aria-hidden="true"
|
||||||
|
></clr-icon>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
{{ libTable.replace('-FC', '') }}
|
{{ libTable.replace('-FC', '') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -114,7 +126,7 @@
|
|||||||
</clr-tree>
|
</clr-tree>
|
||||||
</app-sidebar>
|
</app-sidebar>
|
||||||
|
|
||||||
<div class="content-area">
|
<main class="content-area">
|
||||||
<div class="card-block">
|
<div class="card-block">
|
||||||
<div *ngIf="loading" class="spinner-wrapper-fullpage">
|
<div *ngIf="loading" class="spinner-wrapper-fullpage">
|
||||||
<div class="loadingSpinner">
|
<div class="loadingSpinner">
|
||||||
@@ -144,4 +156,4 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<p><strong>Protocol:</strong> {{ protocol }}</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong>SYSSITE:</strong>
|
<strong>SYSSITE:</strong>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export class LicensingComponent implements OnInit {
|
|||||||
public activationKeyValue: string = ''
|
public activationKeyValue: string = ''
|
||||||
|
|
||||||
public applyingKeys: boolean = false
|
public applyingKeys: boolean = false
|
||||||
|
public protocol: string =
|
||||||
|
location.protocol === 'https:'
|
||||||
|
? 'HTTPS - secure connection'
|
||||||
|
: 'HTTP - insecure connection'
|
||||||
|
|
||||||
public syssite = this.appService.syssite
|
public syssite = this.appService.syssite
|
||||||
public currentLicenceKey = this.licenceService.licenceKey
|
public currentLicenceKey = this.licenceService.licenceKey
|
||||||
|
|||||||
@@ -239,13 +239,7 @@
|
|||||||
|
|
||||||
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
||||||
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
|
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
|
||||||
<div
|
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
|
||||||
*ngIf="!helperService.isMicrosoft"
|
|
||||||
(click)="downloadPNG()"
|
|
||||||
clrDropdownItem
|
|
||||||
>
|
|
||||||
PNG
|
|
||||||
</div>
|
|
||||||
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
|
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
|
||||||
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem>
|
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem>
|
||||||
CSV
|
CSV
|
||||||
@@ -366,13 +360,7 @@
|
|||||||
|
|
||||||
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
||||||
<div (click)="renderToDownload('SVG')" clrDropdownItem>SVG</div>
|
<div (click)="renderToDownload('SVG')" clrDropdownItem>SVG</div>
|
||||||
<div
|
<div (click)="renderToDownload('PNG')" clrDropdownItem>PNG</div>
|
||||||
*ngIf="!helperService.isMicrosoft"
|
|
||||||
(click)="renderToDownload('PNG')"
|
|
||||||
clrDropdownItem
|
|
||||||
>
|
|
||||||
PNG
|
|
||||||
</div>
|
|
||||||
<div (click)="downloadDot(); cancelRenderingGraph()" clrDropdownItem>
|
<div (click)="downloadDot(); cancelRenderingGraph()" clrDropdownItem>
|
||||||
Dot
|
Dot
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export interface CellValidationSource {
|
|||||||
extended_values?: string[]
|
extended_values?: string[]
|
||||||
hash: string
|
hash: string
|
||||||
count: number
|
count: number
|
||||||
|
pending?: Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
class="no-table-selected pointer-events-none"
|
class="no-table-selected pointer-events-none"
|
||||||
>
|
>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
shape="upload-cloud"
|
shape="upload-cloud"
|
||||||
size="40"
|
size="40"
|
||||||
class="is-info icon-dc-fill"
|
class="is-info icon-dc-fill"
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
>
|
>
|
||||||
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed">
|
<ng-container *ngIf="fileLoadingState !== FileLoadingState.parsed">
|
||||||
<clr-icon
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
shape="process-on-vm"
|
shape="process-on-vm"
|
||||||
size="40"
|
size="40"
|
||||||
class="is-info icon-dc-fill"
|
class="is-info icon-dc-fill"
|
||||||
@@ -209,6 +211,7 @@
|
|||||||
|
|
||||||
<ng-container *ngIf="fileLoadingState === FileLoadingState.parsed">
|
<ng-container *ngIf="fileLoadingState === FileLoadingState.parsed">
|
||||||
<clr-icon
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
shape="warning-standard"
|
shape="warning-standard"
|
||||||
size="40"
|
size="40"
|
||||||
class="is-info icon-dc-fill"
|
class="is-info icon-dc-fill"
|
||||||
@@ -372,6 +375,7 @@
|
|||||||
class="no-table-selected pointer-events-none"
|
class="no-table-selected pointer-events-none"
|
||||||
>
|
>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
shape="warning-standard"
|
shape="warning-standard"
|
||||||
size="40"
|
size="40"
|
||||||
class="is-info icon-dc-fill"
|
class="is-info icon-dc-fill"
|
||||||
|
|||||||
@@ -160,14 +160,16 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|||||||
filters: true,
|
filters: true,
|
||||||
stretchH: 'all',
|
stretchH: 'all',
|
||||||
afterGetColHeader: baseAfterGetColHeader,
|
afterGetColHeader: baseAfterGetColHeader,
|
||||||
modifyColWidth: this.maxWidthCheker
|
modifyColWidth: this.maxWidthCheker,
|
||||||
|
theme: 'ht-theme-classic'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude data from settings for HOT v16 - it will be loaded manually
|
// Exclude data from settings for HOT v16 - it will be loaded manually
|
||||||
const { data, ...settingsWithoutData } = this.hotUserDatasets
|
const { data, ...settingsWithoutData } = this.hotUserDatasets
|
||||||
this.hotUserDatasetsSettings = {
|
this.hotUserDatasetsSettings = {
|
||||||
...settingsWithoutData,
|
...settingsWithoutData,
|
||||||
licenseKey: this.hotTableLicenseKey
|
licenseKey: this.hotTableLicenseKey,
|
||||||
|
theme: 'ht-theme-classic'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
|
import { sanitiseForSas } from '../../shared/utils/sanitise'
|
||||||
import { SasStoreService } from '../../services/sas-store.service'
|
import { SasStoreService } from '../../services/sas-store.service'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -136,7 +137,7 @@ export class ApproveDetailsComponent implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
public async rejecting() {
|
public async rejecting() {
|
||||||
this.rejectLoading = true
|
this.rejectLoading = true
|
||||||
this.submitReason = this.submitReason.replace(/\n/g, '. ')
|
this.submitReason = sanitiseForSas(this.submitReason.replace(/\n/g, '. '))
|
||||||
|
|
||||||
let rejParams = {
|
let rejParams = {
|
||||||
STP_ACTION: 'REJECT_TABLE',
|
STP_ACTION: 'REJECT_TABLE',
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { HelperService } from './helper.service'
|
||||||
|
|
||||||
|
describe('HelperService - convertArrayValues', () => {
|
||||||
|
let service: HelperService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// convertArrayValues does not touch any injected dependency
|
||||||
|
service = new HelperService(null as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty array for empty input', () => {
|
||||||
|
expect(service.convertArrayValues([], 'string')).toEqual([])
|
||||||
|
expect(service.convertArrayValues([], 'number')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces values to numbers for the numeric path', () => {
|
||||||
|
expect(service.convertArrayValues(['1', '2', '3'], 'number')).toEqual([
|
||||||
|
1, 2, 3
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces values to strings for the string path', () => {
|
||||||
|
expect(service.convertArrayValues([1, 2, 3], 'string')).toEqual([
|
||||||
|
'1',
|
||||||
|
'2',
|
||||||
|
'3'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: a RAW_VALUE / dropdown source value that itself contains a
|
||||||
|
// comma must stay a single item. Previously `array.toString().split(',')`
|
||||||
|
// joined the array and re-split on every comma, shattering one value into
|
||||||
|
// several. Same bug that hit display_value lists.
|
||||||
|
it('keeps a single value containing commas intact (string path)', () => {
|
||||||
|
expect(service.convertArrayValues(['Smith, John'], 'string')).toEqual([
|
||||||
|
'Smith, John'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not split multiple comma-containing values into individual items', () => {
|
||||||
|
const source = ['Smith, John', 'Doe, Jane', 'Plain']
|
||||||
|
|
||||||
|
const result = service.convertArrayValues(source, 'string')
|
||||||
|
|
||||||
|
expect(result).toEqual(['Smith, John', 'Doe, Jane', 'Plain'])
|
||||||
|
expect(result.length).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,12 +11,8 @@ const librariesToShow = 50
|
|||||||
export class HelperService {
|
export class HelperService {
|
||||||
public shownLibraries: number = librariesToShow
|
public shownLibraries: number = librariesToShow
|
||||||
public loadMoreCount: number = librariesToShow
|
public loadMoreCount: number = librariesToShow
|
||||||
public isMicrosoft: boolean = false
|
|
||||||
|
|
||||||
constructor(private sasService: SasService) {
|
constructor(private sasService: SasService) {}
|
||||||
this.isMicrosoft = this.isIEorEDGE()
|
|
||||||
console.log('Is IE or Edge?', this.isMicrosoft)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a JavaScript date object to a SAS Date or Datetime, given the logic below:
|
* Converts a JavaScript date object to a SAS Date or Datetime, given the logic below:
|
||||||
@@ -215,32 +211,6 @@ export class HelperService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public isIEorEDGE() {
|
|
||||||
var ua = window.navigator.userAgent
|
|
||||||
|
|
||||||
var msie = ua.indexOf('MSIE ')
|
|
||||||
if (msie > 0) {
|
|
||||||
// IE 10 or older => return version number
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
var trident = ua.indexOf('Trident/')
|
|
||||||
if (trident > 0) {
|
|
||||||
// IE 11 => return version number
|
|
||||||
var rv = ua.indexOf('rv:')
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
var edge = ua.indexOf('Edge/')
|
|
||||||
if (edge > 0) {
|
|
||||||
// Edge (IE 12+) => return version number
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// other browser
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
public convertObjectsToArray(
|
public convertObjectsToArray(
|
||||||
objectArray: Array<object>,
|
objectArray: Array<object>,
|
||||||
deepClone: boolean = false
|
deepClone: boolean = false
|
||||||
@@ -310,7 +280,7 @@ export class HelperService {
|
|||||||
return array.map((value) => value * 1)
|
return array.map((value) => value * 1)
|
||||||
}
|
}
|
||||||
case 'string': {
|
case 'string': {
|
||||||
return array.toString().split(',')
|
return array.map((value) => `${value}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,15 +170,15 @@ export class LicenceService {
|
|||||||
errString
|
errString
|
||||||
)}&force=true`
|
)}&force=true`
|
||||||
this.licenceProblem.next(url)
|
this.licenceProblem.next(url)
|
||||||
this.router.navigateByUrl(url)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
return this.applicationActivation(
|
this.applicationActivation(
|
||||||
this.freeTierLicenceData,
|
this.freeTierLicenceData,
|
||||||
variables,
|
variables,
|
||||||
startup_site_id,
|
startup_site_id,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
this.router.navigateByUrl(url)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -155,13 +155,23 @@ export class SasStoreService {
|
|||||||
.adapterResponse
|
.adapterResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private libsPromise: Promise<any> | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @returns All libraries
|
* @returns All libraries
|
||||||
*/
|
*/
|
||||||
public async viewLibs() {
|
public viewLibs() {
|
||||||
return (await this.sasService.request('public/viewlibs', null))
|
if (!this.libsPromise) {
|
||||||
.adapterResponse
|
this.libsPromise = this.sasService
|
||||||
|
.request('public/viewlibs', null)
|
||||||
|
.then((res: any) => res.adapterResponse)
|
||||||
|
.catch((err: any) => {
|
||||||
|
this.libsPromise = null
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this.libsPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
public async refreshLibInfo(libref: string) {
|
public async refreshLibInfo(libref: string) {
|
||||||
|
|||||||
@@ -120,9 +120,12 @@ export class SasViyaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getComputeContexts(): Observable<ViyaComputeContexts> {
|
getComputeContexts(): Observable<ViyaComputeContexts> {
|
||||||
return this.get<ViyaComputeContexts>(`${this.serverUrl}/compute/contexts`, {
|
return this.get<ViyaComputeContexts>(
|
||||||
withCredentials: true
|
`${this.serverUrl}/compute/contexts?limit=1000`,
|
||||||
})
|
{
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
getComputeContextById(id: string): Observable<ComputeContextDetails> {
|
getComputeContextById(id: string): Observable<ComputeContextDetails> {
|
||||||
|
|||||||
@@ -641,6 +641,23 @@ export class SasService {
|
|||||||
this.sasjsAdapter.setDebugState(state)
|
this.sasjsAdapter.setDebugState(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the `&_debug=...` URL segment honoring the live adapter
|
||||||
|
* config. Empty string when debug is off. `128` on the Viya WEB JES path
|
||||||
|
* with `runAsTask` enabled, `131` otherwise.
|
||||||
|
*/
|
||||||
|
public getDebugUrlParam(): string {
|
||||||
|
const config = this.sasjsAdapter.getSasjsConfig()
|
||||||
|
if (!config.debug) return ''
|
||||||
|
const value =
|
||||||
|
config.serverType === ServerType.SasViya &&
|
||||||
|
config.useComputeApi === null &&
|
||||||
|
config.runAsTask === true
|
||||||
|
? 128
|
||||||
|
: 131
|
||||||
|
return `&_debug=${value}`
|
||||||
|
}
|
||||||
|
|
||||||
public getSasjsInstance() {
|
public getSasjsInstance() {
|
||||||
return this.sasjsAdapter
|
return this.sasjsAdapter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<clr-modal
|
||||||
|
[clrModalOpen]="open"
|
||||||
|
[clrModalClosable]="false"
|
||||||
|
[clrModalStaticBackdrop]="true"
|
||||||
|
[clrModalSize]="'sm'"
|
||||||
|
>
|
||||||
|
<h3 class="modal-title">Validating cells</h3>
|
||||||
|
<div class="modal-body bulk-validation-body">
|
||||||
|
<div class="bulk-validation-row">
|
||||||
|
<clr-spinner clrSmall></clr-spinner>
|
||||||
|
<span class="bulk-validation-text">
|
||||||
|
Validating {{ done }} / {{ total }}…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<clr-progress-bar
|
||||||
|
class="bulk-validation-progress"
|
||||||
|
[clrValue]="done"
|
||||||
|
[clrMax]="total"
|
||||||
|
></clr-progress-bar>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" (click)="onCancel()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</clr-modal>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.bulk-validation-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.bulk-validation-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-validation-text {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-validation-progress {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-bulk-validation-modal',
|
||||||
|
templateUrl: './bulk-validation-modal.component.html',
|
||||||
|
styleUrls: ['./bulk-validation-modal.component.scss'],
|
||||||
|
standalone: false
|
||||||
|
})
|
||||||
|
export class BulkValidationModalComponent {
|
||||||
|
@Input() open = false
|
||||||
|
@Input() done = 0
|
||||||
|
@Input() total = 0
|
||||||
|
|
||||||
|
@Output() cancel = new EventEmitter<void>()
|
||||||
|
|
||||||
|
onCancel() {
|
||||||
|
this.cancel.emit()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<clr-modal
|
||||||
|
[clrModalOpen]="open"
|
||||||
|
(clrModalOpenChange)="onClrModalOpenChange($event)"
|
||||||
|
[clrModalSize]="'md'"
|
||||||
|
>
|
||||||
|
<h3 class="modal-title">{{ title }}</h3>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div>{{ message }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline" (click)="onCancel()">
|
||||||
|
{{ cancelText }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" (click)="onConfirm()">
|
||||||
|
{{ confirmText }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</clr-modal>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-confirm-modal',
|
||||||
|
templateUrl: './confirm-modal.component.html',
|
||||||
|
standalone: false
|
||||||
|
})
|
||||||
|
export class ConfirmModalComponent {
|
||||||
|
@Input() open = false
|
||||||
|
@Input() title = 'Confirm'
|
||||||
|
@Input() message = ''
|
||||||
|
@Input() confirmText = 'Yes'
|
||||||
|
@Input() cancelText = 'No'
|
||||||
|
|
||||||
|
@Output() result = new EventEmitter<boolean>()
|
||||||
|
|
||||||
|
onConfirm() {
|
||||||
|
this.result.emit(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel() {
|
||||||
|
this.result.emit(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClrModalOpenChange(value: boolean) {
|
||||||
|
// Close triggered by X / outside / Esc — treat as cancel. Only emit
|
||||||
|
// when modal was actually open (avoid double-emit when parent closes
|
||||||
|
// us via [open]=false in response to the Yes/No button).
|
||||||
|
if (!value && this.open) this.result.emit(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { datetimeValidator } from '../validations/hot-custom-validators'
|
||||||
|
import { DATETIME_FORMAT } from '../utils/mapIntlCellTypes'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell types for the date/time/datetime columns after the HOT 17 migration.
|
||||||
|
*
|
||||||
|
* `date` and `time` use Handsontable's BUILT-IN `intl-date` / `intl-time` cell
|
||||||
|
* types as-is — a native (browser/OS) picker editor plus the Intl renderer that
|
||||||
|
* formats the displayed value per the cell's `locale` + `dateFormat`/`timeFormat`
|
||||||
|
* (set as cell meta in mapIntlCellTypes). They are intentionally NOT re-registered
|
||||||
|
* here, so their localizing `valueFormatter` stays active. The stored/submitted
|
||||||
|
* value remains the raw ISO string; only the display is localized.
|
||||||
|
*
|
||||||
|
* Only `intl-datetime` needs registering — HOT has no built-in datetime type. It
|
||||||
|
* pairs a native `datetime-local` picker with a `valueFormatter` that formats the
|
||||||
|
* stored `YYYY-MM-DD HH:mm:ss` per the cell's locale (mirroring how the built-in
|
||||||
|
* intl-date/intl-time types localize, via tableView's `renderer.valueFormatter`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SPACE_ISO = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2})?)$/
|
||||||
|
|
||||||
|
const BaseTextEditor = Handsontable.editors.getEditor('text') as any
|
||||||
|
const baseTextRenderer = Handsontable.renderers.getRenderer('text')
|
||||||
|
|
||||||
|
class IntlDatetimeEditor extends BaseTextEditor {
|
||||||
|
createElements() {
|
||||||
|
super.createElements('input')
|
||||||
|
this.TEXTAREA.setAttribute('type', 'datetime-local')
|
||||||
|
this.TEXTAREA.setAttribute('step', '1') // allow seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored `YYYY-MM-DD HH:mm:ss` → native input `YYYY-MM-DDTHH:mm:ss`.
|
||||||
|
setValue(value: any) {
|
||||||
|
const match = typeof value === 'string' ? value.match(SPACE_ISO) : null
|
||||||
|
super.setValue(match ? `${match[1]}T${match[2]}` : value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native input `YYYY-MM-DDTHH:mm[:ss]` → stored `YYYY-MM-DD HH:mm:ss`.
|
||||||
|
getValue() {
|
||||||
|
const raw: string = super.getValue()
|
||||||
|
if (typeof raw !== 'string' || raw.indexOf('T') === -1) return raw
|
||||||
|
const [date, time] = raw.split('T')
|
||||||
|
const withSeconds = time.length === 5 ? `${time}:00` : time // pad HH:mm
|
||||||
|
return `${date} ${withSeconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
super.open()
|
||||||
|
try {
|
||||||
|
this.TEXTAREA.showPicker()
|
||||||
|
} catch {
|
||||||
|
/* showPicker requires a user gesture; ignore when called programmatically */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The base text editor's focus() calls select() + setSelectionRange to place
|
||||||
|
// the caret, but a native `datetime-local` input does not support text
|
||||||
|
// selection and throws `InvalidStateError`. Just focus the input.
|
||||||
|
focus() {
|
||||||
|
this.TEXTAREA.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localizes a stored `YYYY-MM-DD HH:mm:ss` datetime per the cell's locale.
|
||||||
|
* Invoked by Handsontable via `renderer.valueFormatter` (tableView) before the
|
||||||
|
* text renderer paints the cell, so the stored value is never mutated. Falls
|
||||||
|
* back to the raw value when it is empty or unparseable.
|
||||||
|
*/
|
||||||
|
function intlDatetimeValueFormatter(value: any, cellProperties: any): any {
|
||||||
|
if (typeof value !== 'string') return value
|
||||||
|
|
||||||
|
const match = value.match(SPACE_ISO)
|
||||||
|
if (!match) return value
|
||||||
|
|
||||||
|
const date = new Date(`${match[1]}T${match[2]}`)
|
||||||
|
if (isNaN(date.getTime())) return value
|
||||||
|
|
||||||
|
const options: Intl.DateTimeFormatOptions =
|
||||||
|
cellProperties.datetimeFormat || DATETIME_FORMAT
|
||||||
|
const locale = cellProperties.locale || window.navigator.language
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale, options).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers the `intl-datetime` cell type. Idempotent — safe to call per
|
||||||
|
* DcValidator construction. `intl-date`/`intl-time` are not registered here;
|
||||||
|
* the built-in HOT types are used directly.
|
||||||
|
*/
|
||||||
|
export const registerIntlCellTypes = () => {
|
||||||
|
Handsontable.cellTypes.registerCellType('intl-datetime', {
|
||||||
|
// IntlDatetimeEditor extends the (loosely-typed) text editor returned by
|
||||||
|
// getEditor('text'); cast to satisfy registerCellType's editor signature.
|
||||||
|
editor: IntlDatetimeEditor as any,
|
||||||
|
renderer: baseTextRenderer,
|
||||||
|
validator: datetimeValidator,
|
||||||
|
valueFormatter: intlDatetimeValueFormatter
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ import Handsontable from 'handsontable'
|
|||||||
import { CellProperties } from 'handsontable/settings'
|
import { CellProperties } from 'handsontable/settings'
|
||||||
import {
|
import {
|
||||||
autocompleteValidator,
|
autocompleteValidator,
|
||||||
dateValidator,
|
intlDateValidator,
|
||||||
timeValidator
|
intlTimeValidator
|
||||||
} from 'handsontable/validators'
|
} from 'handsontable/validators'
|
||||||
import { $DataFormats } from 'src/app/models/sas/editors-getdata.model'
|
import { $DataFormats } from 'src/app/models/sas/editors-getdata.model'
|
||||||
import { DQData, SASParam } from '../../models/TableData'
|
import { DQData, SASParam } from '../../models/TableData'
|
||||||
@@ -15,12 +15,19 @@ import {
|
|||||||
} from './models/dc-validation.model'
|
} from './models/dc-validation.model'
|
||||||
import { DQRule, DQRuleTypes } from './models/dq-rules.model'
|
import { DQRule, DQRuleTypes } from './models/dq-rules.model'
|
||||||
import { getDqDataCols } from './utils/getDqDataCols'
|
import { getDqDataCols } from './utils/getDqDataCols'
|
||||||
|
import { getNotNullDefault } from './utils/getNotNullDefault'
|
||||||
import { mergeColsRules } from './utils/mergeColsRules'
|
import { mergeColsRules } from './utils/mergeColsRules'
|
||||||
import { parseColType } from './utils/parseColType'
|
import { parseColType } from './utils/parseColType'
|
||||||
import { dqValidate } from './validations/dq-validation'
|
import { dqValidate } from './validations/dq-validation'
|
||||||
import { specialMissingNumericValidator } from './validations/hot-custom-validators'
|
import {
|
||||||
|
datetimeValidator,
|
||||||
|
specialMissingNumericValidator
|
||||||
|
} from './validations/hot-custom-validators'
|
||||||
import { applyNumericFormats } from './utils/applyNumericFormats'
|
import { applyNumericFormats } from './utils/applyNumericFormats'
|
||||||
|
import { mapIntlCellTypes } from './utils/mapIntlCellTypes'
|
||||||
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
|
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
|
||||||
|
import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
|
||||||
|
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
|
||||||
|
|
||||||
export class DcValidator {
|
export class DcValidator {
|
||||||
private rules: DcValidation[] = []
|
private rules: DcValidation[] = []
|
||||||
@@ -46,6 +53,7 @@ export class DcValidator {
|
|||||||
this.rules = parseColType(sasparams.COLTYPE)
|
this.rules = parseColType(sasparams.COLTYPE)
|
||||||
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
|
this.rules = mergeColsRules(cols, this.rules, $dataFormats)
|
||||||
this.rules = applyNumericFormats(this.rules)
|
this.rules = applyNumericFormats(this.rules)
|
||||||
|
this.rules = mapIntlCellTypes(this.rules)
|
||||||
this.dqrules = dqRules
|
this.dqrules = dqRules
|
||||||
this.dqdata = dqData
|
this.dqdata = dqData
|
||||||
this.primaryKeys = sasparams.PK.split(' ')
|
this.primaryKeys = sasparams.PK.split(' ')
|
||||||
@@ -59,6 +67,7 @@ export class DcValidator {
|
|||||||
'autocomplete.custom',
|
'autocomplete.custom',
|
||||||
CustomAutocompleteEditor
|
CustomAutocompleteEditor
|
||||||
)
|
)
|
||||||
|
registerIntlCellTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
getRules(): DcValidation[] {
|
getRules(): DcValidation[] {
|
||||||
@@ -133,6 +142,38 @@ export class DcValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the RULE_VALUE for a NOTNULL rule on the given column.
|
||||||
|
* Used for auto-populating default values when cells are empty.
|
||||||
|
* Converts to number for numeric columns.
|
||||||
|
*
|
||||||
|
* @param col column name
|
||||||
|
* @returns RULE_VALUE (string or number) if NOTNULL rule exists, otherwise undefined
|
||||||
|
*/
|
||||||
|
getNotNullDefaultValue(col: string): string | number | undefined {
|
||||||
|
const colRule = this.getRule(col)
|
||||||
|
return getNotNullDefault(col, this.dqrules, colRule?.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the num_digits for a ROUND rule on the given column, used to
|
||||||
|
* round edited/pasted values Excel-style. Returns undefined if no ROUND
|
||||||
|
* rule exists or its RULE_VALUE is not an integer.
|
||||||
|
*
|
||||||
|
* @param col column name
|
||||||
|
*/
|
||||||
|
getRoundDigits(col: string): number | undefined {
|
||||||
|
const roundRule = this.dqrules.find(
|
||||||
|
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'ROUND'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!roundRule) return undefined
|
||||||
|
|
||||||
|
const digits = parseInt(roundRule.RULE_VALUE, 10)
|
||||||
|
|
||||||
|
return isNaN(digits) ? undefined : digits
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves dropdown source for given dc validation rule
|
* Retrieves dropdown source for given dc validation rule
|
||||||
* The values comes from MPE_SELECTBOX table
|
* The values comes from MPE_SELECTBOX table
|
||||||
@@ -224,7 +265,6 @@ export class DcValidator {
|
|||||||
const cellProperties: CellProperties = {
|
const cellProperties: CellProperties = {
|
||||||
...columnRules,
|
...columnRules,
|
||||||
validator: undefined,
|
validator: undefined,
|
||||||
correctFormat: false,
|
|
||||||
row: 0,
|
row: 0,
|
||||||
col: 0,
|
col: 0,
|
||||||
instance: new Handsontable(document.createElement('div'), {}),
|
instance: new Handsontable(document.createElement('div'), {}),
|
||||||
@@ -270,10 +310,18 @@ export class DcValidator {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (source.length > 0) {
|
if (source.length > 0) {
|
||||||
|
// For SAS num cols keep type='numeric' so HOT's numericEditor /
|
||||||
|
// numericRenderer + numbro coercion stay alive — same per-column
|
||||||
|
// model as the per-cell pattern in
|
||||||
|
// editor.component.ts:reSetCellValidationValues().
|
||||||
this.rules[i].source = source
|
this.rules[i].source = source
|
||||||
this.rules[i].type = 'autocomplete'
|
|
||||||
this.rules[i].editor = 'autocomplete.custom'
|
this.rules[i].editor = 'autocomplete.custom'
|
||||||
|
this.rules[i].renderer = 'autocomplete'
|
||||||
this.rules[i].filter = false
|
this.rules[i].filter = false
|
||||||
|
|
||||||
|
if (this.rules[i].sasType !== 'num') {
|
||||||
|
this.rules[i].type = 'autocomplete'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.hasDqRules(ruleColName, ['SOFTSELECT'])) {
|
if (this.hasDqRules(ruleColName, ['SOFTSELECT'])) {
|
||||||
@@ -295,11 +343,31 @@ export class DcValidator {
|
|||||||
if (this.hasDqRules(ruleColName, ['NOTNULL'])) {
|
if (this.hasDqRules(ruleColName, ['NOTNULL'])) {
|
||||||
this.rules[i].allowEmpty = false
|
this.rules[i].allowEmpty = false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Correct format comes as STRING from SAS. That could be also fixed on SAS side.
|
// READONLY: render column read-only (default value handled via getColumnDefault on add row)
|
||||||
if ((this.rules[i].correctFormat as any) === 'true') {
|
if (this.hasDqRules(ruleColName, ['READONLY'])) {
|
||||||
this.rules[i].correctFormat = true
|
this.rules[i].readOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData())
|
||||||
|
if (this.hasDqRules(ruleColName, ['HIDDEN'])) {
|
||||||
|
this.hiddenColumns.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NUMBER_FORMAT: display-only Intl.NumberFormat renderer.
|
||||||
|
// Set last so it overrides a dropdown's 'autocomplete' renderer (last-wins).
|
||||||
|
if (this.hasDqRules(ruleColName, ['NUMBER_FORMAT'])) {
|
||||||
|
const fmtRule = this.getDqDetails(ruleColName).find(
|
||||||
|
(rule: DQRule) => rule.RULE_TYPE === 'NUMBER_FORMAT'
|
||||||
|
)
|
||||||
|
this.rules[i].renderer = makeNumberFormatRenderer(fmtRule?.RULE_VALUE)
|
||||||
|
// Clear numericFormat (set by applyNumericFormats on every numeric
|
||||||
|
// column). On a numeric cell HOT's built-in numeric renderer would
|
||||||
|
// re-format via numericFormat, overriding our custom NUMBER_FORMAT
|
||||||
|
// Intl renderer and dropping the currency symbol. The numeric
|
||||||
|
// editor/validator stay intact via `type`.
|
||||||
|
this.rules[i].numericFormat = undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const self = this
|
const self = this
|
||||||
@@ -441,10 +509,12 @@ export class DcValidator {
|
|||||||
return autocompleteValidator
|
return autocompleteValidator
|
||||||
case 'numeric':
|
case 'numeric':
|
||||||
return specialMissingNumericValidator
|
return specialMissingNumericValidator
|
||||||
case 'date':
|
case 'intl-date':
|
||||||
return dateValidator
|
return intlDateValidator
|
||||||
case 'time':
|
case 'intl-time':
|
||||||
return timeValidator
|
return intlTimeValidator
|
||||||
|
case 'intl-datetime':
|
||||||
|
return datetimeValidator
|
||||||
default:
|
default:
|
||||||
return (value: any, callback: any) => (callback ? callback(true) : null)
|
return (value: any, callback: any) => (callback ? callback(true) : null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface DcColumnSettings {
|
|||||||
valid?: boolean
|
valid?: boolean
|
||||||
desc?: string
|
desc?: string
|
||||||
clsRule?: string
|
clsRule?: string
|
||||||
|
// SAS-side column type from $dataFormats (e.g. 'num', 'char') — distinct
|
||||||
|
// from Handsontable's `type` which drives renderer/editor selection
|
||||||
|
sasType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DcValidation extends HotColumnSettings, DcColumnSettings {}
|
export interface DcValidation extends HotColumnSettings, DcColumnSettings {}
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ export type DQRuleTypes =
|
|||||||
| 'CASE'
|
| 'CASE'
|
||||||
| 'MINVAL'
|
| 'MINVAL'
|
||||||
| 'MAXVAL'
|
| 'MAXVAL'
|
||||||
|
| 'READONLY'
|
||||||
|
| 'HIDDEN'
|
||||||
|
| 'ROUND'
|
||||||
|
| 'NUMBER_FORMAT'
|
||||||
|
|||||||
@@ -34,10 +34,27 @@ describe('DC Validator', () => {
|
|||||||
// Check allowEmpty - it is notnull rule so allowEmpty should be false
|
// Check allowEmpty - it is notnull rule so allowEmpty should be false
|
||||||
expect(someNumRule?.allowEmpty).toBeFalse()
|
expect(someNumRule?.allowEmpty).toBeFalse()
|
||||||
|
|
||||||
// Get col with notnull validation
|
// SAS sends semantic date/time/datetime column types; the frontend maps them
|
||||||
|
// to the HOT 17 Intl cell types and attaches locale + Intl format meta so the
|
||||||
|
// cell display is localized (see mapIntlCellTypes). The deprecated
|
||||||
|
// correctFormat + moment format strings are no longer sent in the payload.
|
||||||
const someDateRule = dcValidator.getRule('SOME_DATE')
|
const someDateRule = dcValidator.getRule('SOME_DATE')
|
||||||
// Check correctFormat - it comes from SAS - should be true
|
expect(someDateRule?.correctFormat).toBeUndefined()
|
||||||
expect(someDateRule?.correctFormat).toBeTrue()
|
expect(someDateRule?.type).toBe('intl-date')
|
||||||
|
expect(someDateRule?.dateFormat).toBeDefined()
|
||||||
|
expect((someDateRule as any)?.locale).toBeTruthy()
|
||||||
|
|
||||||
|
const someDatetimeRule = dcValidator.getRule('SOME_DATETIME')
|
||||||
|
expect(someDatetimeRule?.type).toBe('intl-datetime')
|
||||||
|
expect(someDatetimeRule?.correctFormat).toBeUndefined()
|
||||||
|
expect((someDatetimeRule as any)?.datetimeFormat).toBeDefined()
|
||||||
|
expect((someDatetimeRule as any)?.locale).toBeTruthy()
|
||||||
|
|
||||||
|
const someTimeRule = dcValidator.getRule('SOME_TIME')
|
||||||
|
expect(someTimeRule?.type).toBe('intl-time')
|
||||||
|
expect(someTimeRule?.correctFormat).toBeUndefined()
|
||||||
|
expect((someTimeRule as any)?.timeFormat).toBeDefined()
|
||||||
|
expect((someTimeRule as any)?.locale).toBeTruthy()
|
||||||
|
|
||||||
// Get col with dropdown, check if merging sas data is correct
|
// Get col with dropdown, check if merging sas data is correct
|
||||||
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
|
const someDropdownRule = dcValidator.getRule('SOME_DROPDOWN')
|
||||||
@@ -235,6 +252,37 @@ describe('DC Validator', () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should apply READONLY, HIDDEN, NUMBER_FORMAT and ROUND rules', () => {
|
||||||
|
const dcValidator: DcValidator = new DcValidator(
|
||||||
|
example_sasparams,
|
||||||
|
example_dataformats,
|
||||||
|
example_cols,
|
||||||
|
example_dqRules,
|
||||||
|
example_dqData
|
||||||
|
)
|
||||||
|
|
||||||
|
// READONLY -> column rendered read-only
|
||||||
|
expect(dcValidator.getRule('SOME_BESTNUM')?.readOnly).toBeTrue()
|
||||||
|
|
||||||
|
// NUMBER_FORMAT -> a function renderer is assigned and numbro numericFormat
|
||||||
|
// is cleared so it can't override the Intl currency/percent rendering
|
||||||
|
expect(typeof dcValidator.getRule('SOME_BESTNUM')?.renderer).toEqual(
|
||||||
|
'function'
|
||||||
|
)
|
||||||
|
expect(dcValidator.getRule('SOME_BESTNUM')?.numericFormat).toBeUndefined()
|
||||||
|
|
||||||
|
// HIDDEN -> column index added to hidden columns
|
||||||
|
const rules = dcValidator.getRules()
|
||||||
|
const hiddenIndex = rules.findIndex(
|
||||||
|
(rule) => rule.data === 'PRIMARY_KEY_FIELD'
|
||||||
|
)
|
||||||
|
expect(dcValidator.getHiddenColumns()).toContain(hiddenIndex)
|
||||||
|
|
||||||
|
// ROUND -> num_digits returned for the column, undefined otherwise
|
||||||
|
expect(dcValidator.getRoundDigits('SOME_SHORTNUM')).toEqual(2)
|
||||||
|
expect(dcValidator.getRoundDigits('SOME_NUM')).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const example_dqData = [
|
const example_dqData = [
|
||||||
@@ -313,6 +361,30 @@ const example_dqRules: any = [
|
|||||||
RULE_TYPE: 'CASE',
|
RULE_TYPE: 'CASE',
|
||||||
RULE_VALUE: 'LOWCASE',
|
RULE_VALUE: 'LOWCASE',
|
||||||
X: 0
|
X: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BASE_COL: 'SOME_BESTNUM',
|
||||||
|
RULE_TYPE: 'READONLY',
|
||||||
|
RULE_VALUE: '7',
|
||||||
|
X: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BASE_COL: 'SOME_BESTNUM',
|
||||||
|
RULE_TYPE: 'NUMBER_FORMAT',
|
||||||
|
RULE_VALUE: '{"minimumFractionDigits":2}',
|
||||||
|
X: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BASE_COL: 'PRIMARY_KEY_FIELD',
|
||||||
|
RULE_TYPE: 'HIDDEN',
|
||||||
|
RULE_VALUE: '99',
|
||||||
|
X: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BASE_COL: 'SOME_SHORTNUM',
|
||||||
|
RULE_TYPE: 'ROUND',
|
||||||
|
RULE_VALUE: '2',
|
||||||
|
X: 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -379,21 +451,15 @@ const example_COLTYPE = `
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"data":"SOME_DATE",
|
"data":"SOME_DATE",
|
||||||
"type":"date",
|
"type":"date"
|
||||||
"dateFormat":"YYYY-MM-DD",
|
|
||||||
"correctFormat":"true"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"data":"SOME_DATETIME",
|
"data":"SOME_DATETIME",
|
||||||
"type":"date",
|
"type":"datetime"
|
||||||
"dateFormat":"YYYY-MM-DD HH:mm:ss.SSS",
|
|
||||||
"correctFormat":"true"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"data":"SOME_TIME",
|
"data":"SOME_TIME",
|
||||||
"type":"time",
|
"type":"time"
|
||||||
"timeFormat":"HH:mm:ss.SSS",
|
|
||||||
"correctFormat":"true"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"data":"SOME_SHORTNUM",
|
"data":"SOME_SHORTNUM",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { excelRound } from '../utils/excelRound'
|
||||||
|
|
||||||
|
describe('DC Validator - excelRound', () => {
|
||||||
|
it('should round to the given number of decimal places', () => {
|
||||||
|
expect(excelRound(1.23456, 2)).toEqual(1.23)
|
||||||
|
expect(excelRound(1.236, 2)).toEqual(1.24)
|
||||||
|
expect(excelRound(2.5, 0)).toEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should round half away from zero for negative values', () => {
|
||||||
|
expect(excelRound(-0.5, 0)).toEqual(-1)
|
||||||
|
expect(excelRound(-2.5, 0)).toEqual(-3)
|
||||||
|
expect(excelRound(-1.23456, 2)).toEqual(-1.23)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support negative digits (round to tens/hundreds)', () => {
|
||||||
|
expect(excelRound(25, -1)).toEqual(30)
|
||||||
|
expect(excelRound(24, -1)).toEqual(20)
|
||||||
|
expect(excelRound(150, -2)).toEqual(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Examples from Microsoft's ROUND documentation:
|
||||||
|
// https://support.microsoft.com/en-us/office/round-function-c018c5d8-40fb-4053-90b1-b3e7f61a213c
|
||||||
|
it('should match the Microsoft ROUND examples', () => {
|
||||||
|
expect(excelRound(2.15, 1)).toEqual(2.2)
|
||||||
|
expect(excelRound(2.149, 1)).toEqual(2.1)
|
||||||
|
expect(excelRound(-1.475, 2)).toEqual(-1.48)
|
||||||
|
expect(excelRound(21.5, -1)).toEqual(20)
|
||||||
|
expect(excelRound(626.3, -3)).toEqual(1000)
|
||||||
|
expect(excelRound(1.98, -1)).toEqual(0)
|
||||||
|
expect(excelRound(-50.55, -2)).toEqual(-100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the original value when not finite', () => {
|
||||||
|
expect(excelRound(NaN, 2)).toBeNaN()
|
||||||
|
expect(excelRound(Infinity, 2)).toEqual(Infinity)
|
||||||
|
expect(excelRound(5, NaN)).toEqual(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { DQRule } from '../models/dq-rules.model'
|
||||||
|
import { getColumnDefault } from '../utils/getColumnDefault'
|
||||||
|
|
||||||
|
describe('DC Validator - getColumnDefault', () => {
|
||||||
|
const dqRules: DQRule[] = [
|
||||||
|
{ BASE_COL: 'NOTNULL_COL', RULE_TYPE: 'NOTNULL', RULE_VALUE: 'nn', X: 1 },
|
||||||
|
{ BASE_COL: 'READONLY_COL', RULE_TYPE: 'READONLY', RULE_VALUE: 'ro', X: 1 },
|
||||||
|
{ BASE_COL: 'HIDDEN_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: 'hd', X: 1 },
|
||||||
|
{ BASE_COL: 'NUM_COL', RULE_TYPE: 'READONLY', RULE_VALUE: '42', X: 1 },
|
||||||
|
{ BASE_COL: 'EMPTY_COL', RULE_TYPE: 'HIDDEN', RULE_VALUE: ' ', X: 1 },
|
||||||
|
{ BASE_COL: 'OTHER_COL', RULE_TYPE: 'HARDSELECT', RULE_VALUE: 'x', X: 1 }
|
||||||
|
]
|
||||||
|
|
||||||
|
it('should return defaults for NOTNULL, READONLY and HIDDEN rules', () => {
|
||||||
|
expect(getColumnDefault('NOTNULL_COL', dqRules, 'text')).toEqual('nn')
|
||||||
|
expect(getColumnDefault('READONLY_COL', dqRules, 'text')).toEqual('ro')
|
||||||
|
expect(getColumnDefault('HIDDEN_COL', dqRules, 'text')).toEqual('hd')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should coerce to number for numeric columns', () => {
|
||||||
|
expect(getColumnDefault('NUM_COL', dqRules, 'numeric')).toEqual(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return string for numeric columns when value is non-numeric', () => {
|
||||||
|
expect(getColumnDefault('READONLY_COL', dqRules, 'numeric')).toEqual('ro')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore empty RULE_VALUE', () => {
|
||||||
|
expect(getColumnDefault('EMPTY_COL', dqRules, 'text')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for rules that do not provide defaults', () => {
|
||||||
|
expect(getColumnDefault('OTHER_COL', dqRules, 'text')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for non-existent columns and empty rules', () => {
|
||||||
|
expect(getColumnDefault('MISSING', dqRules, 'text')).toBeUndefined()
|
||||||
|
expect(getColumnDefault('NOTNULL_COL', [], 'text')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DQRule } from '../models/dq-rules.model'
|
||||||
import { getHotDataSchema } from '../utils/getHotDataSchema'
|
import { getHotDataSchema } from '../utils/getHotDataSchema'
|
||||||
|
|
||||||
describe('DC Validator - hot data schema', () => {
|
describe('DC Validator - hot data schema', () => {
|
||||||
@@ -8,4 +9,58 @@ describe('DC Validator - hot data schema', () => {
|
|||||||
).toEqual(1)
|
).toEqual(1)
|
||||||
expect(getHotDataSchema('missing')).toEqual('')
|
expect(getHotDataSchema('missing')).toEqual('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('NOTNULL defaults', () => {
|
||||||
|
const dqRules: DQRule[] = [
|
||||||
|
{
|
||||||
|
BASE_COL: 'TEXT_COL',
|
||||||
|
RULE_TYPE: 'NOTNULL',
|
||||||
|
RULE_VALUE: 'default_text',
|
||||||
|
X: 1
|
||||||
|
},
|
||||||
|
{ BASE_COL: 'NUM_COL', RULE_TYPE: 'NOTNULL', RULE_VALUE: '42', X: 1 }
|
||||||
|
]
|
||||||
|
|
||||||
|
it('should return NOTNULL default for text column', () => {
|
||||||
|
expect(getHotDataSchema('text', { data: 'TEXT_COL' }, dqRules)).toEqual(
|
||||||
|
'default_text'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return NOTNULL default as number for numeric column', () => {
|
||||||
|
expect(getHotDataSchema('numeric', { data: 'NUM_COL' }, dqRules)).toEqual(
|
||||||
|
42
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fall back to type default when no NOTNULL rule exists', () => {
|
||||||
|
expect(
|
||||||
|
getHotDataSchema('numeric', { data: 'OTHER_COL' }, dqRules)
|
||||||
|
).toEqual('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prioritize NOTNULL over autocomplete first option', () => {
|
||||||
|
const rulesWithAutocomplete: DQRule[] = [
|
||||||
|
{
|
||||||
|
BASE_COL: 'SELECT_COL',
|
||||||
|
RULE_TYPE: 'NOTNULL',
|
||||||
|
RULE_VALUE: 'priority_value',
|
||||||
|
X: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
BASE_COL: 'SELECT_COL',
|
||||||
|
RULE_TYPE: 'HARDSELECT',
|
||||||
|
RULE_VALUE: 'ignored',
|
||||||
|
X: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
getHotDataSchema(
|
||||||
|
'autocomplete',
|
||||||
|
{ data: 'SELECT_COL', source: ['first', 'second'] },
|
||||||
|
rulesWithAutocomplete
|
||||||
|
)
|
||||||
|
).toEqual('priority_value')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { DQRule } from '../models/dq-rules.model'
|
||||||
|
import { getNotNullDefault } from '../utils/getNotNullDefault'
|
||||||
|
|
||||||
|
describe('DC Validator - getNotNullDefault', () => {
|
||||||
|
const dqRules: DQRule[] = [
|
||||||
|
{
|
||||||
|
BASE_COL: 'TEXT_COL',
|
||||||
|
RULE_TYPE: 'NOTNULL',
|
||||||
|
RULE_VALUE: 'default_text',
|
||||||
|
X: 1
|
||||||
|
},
|
||||||
|
{ BASE_COL: 'NUM_COL', RULE_TYPE: 'NOTNULL', RULE_VALUE: '42', X: 1 },
|
||||||
|
{ BASE_COL: 'EMPTY_RULE', RULE_TYPE: 'NOTNULL', RULE_VALUE: ' ', X: 1 },
|
||||||
|
{
|
||||||
|
BASE_COL: 'OTHER_COL',
|
||||||
|
RULE_TYPE: 'HARDSELECT',
|
||||||
|
RULE_VALUE: 'some_value',
|
||||||
|
X: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
it('should return string value for text columns', () => {
|
||||||
|
expect(getNotNullDefault('TEXT_COL', dqRules, 'text')).toEqual(
|
||||||
|
'default_text'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return number for numeric columns when RULE_VALUE is numeric', () => {
|
||||||
|
expect(getNotNullDefault('NUM_COL', dqRules, 'numeric')).toEqual(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return string for numeric columns when RULE_VALUE is not numeric', () => {
|
||||||
|
const rulesWithNonNumeric: DQRule[] = [
|
||||||
|
{
|
||||||
|
BASE_COL: 'NUM_COL',
|
||||||
|
RULE_TYPE: 'NOTNULL',
|
||||||
|
RULE_VALUE: 'not_a_number',
|
||||||
|
X: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
getNotNullDefault('NUM_COL', rulesWithNonNumeric, 'numeric')
|
||||||
|
).toEqual('not_a_number')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for empty RULE_VALUE', () => {
|
||||||
|
expect(getNotNullDefault('EMPTY_RULE', dqRules, 'text')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for columns without NOTNULL rule', () => {
|
||||||
|
expect(getNotNullDefault('OTHER_COL', dqRules, 'text')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for non-existent columns', () => {
|
||||||
|
expect(getNotNullDefault('MISSING_COL', dqRules, 'text')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return undefined for empty dqRules array', () => {
|
||||||
|
expect(getNotNullDefault('TEXT_COL', [], 'text')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return string when colType is undefined', () => {
|
||||||
|
expect(getNotNullDefault('NUM_COL', dqRules, undefined)).toEqual('42')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { isEmpty } from '../utils/isEmpty'
|
||||||
|
|
||||||
|
describe('DC Validator - isEmpty', () => {
|
||||||
|
it('should return true for null', () => {
|
||||||
|
expect(isEmpty(null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true for undefined', () => {
|
||||||
|
expect(isEmpty(undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true for empty string', () => {
|
||||||
|
expect(isEmpty('')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true for whitespace-only string', () => {
|
||||||
|
expect(isEmpty(' ')).toBe(true)
|
||||||
|
expect(isEmpty('\t\n')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for non-empty string', () => {
|
||||||
|
expect(isEmpty('hello')).toBe(false)
|
||||||
|
expect(isEmpty(' hello ')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for number zero', () => {
|
||||||
|
expect(isEmpty(0)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for non-zero numbers', () => {
|
||||||
|
expect(isEmpty(42)).toBe(false)
|
||||||
|
expect(isEmpty(-1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false for boolean values', () => {
|
||||||
|
expect(isEmpty(true)).toBe(false)
|
||||||
|
expect(isEmpty(false)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -38,11 +38,52 @@ describe('DC Validator - merge spec rules', () => {
|
|||||||
data: 'test_col',
|
data: 'test_col',
|
||||||
desc: 'test_desc',
|
desc: 'test_desc',
|
||||||
clsRule: 'cls_rule',
|
clsRule: 'cls_rule',
|
||||||
length: 8
|
length: 8,
|
||||||
|
sasType: 'test_type'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
expect(mergeColsRules(cols, rules, $dataFormats)).toEqual(expected)
|
expect(mergeColsRules(cols, rules, $dataFormats)).toEqual(expected)
|
||||||
expect(cols[0].TYPE).toEqual('test_type')
|
expect(cols[0].TYPE).toEqual('test_type')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should populate sasType for num and char cols', () => {
|
||||||
|
const rules: DcValidation[] = [{ data: 'num_col' }, { data: 'char_col' }]
|
||||||
|
const cols: Col[] = [
|
||||||
|
{
|
||||||
|
NAME: 'num_col',
|
||||||
|
MEMLABEL: '',
|
||||||
|
DESC: '',
|
||||||
|
LONGDESC: '',
|
||||||
|
TYPE: '',
|
||||||
|
CLS_RULE: '',
|
||||||
|
VARNUM: 0,
|
||||||
|
LABEL: '',
|
||||||
|
FMTNAME: '',
|
||||||
|
DDTYPE: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
NAME: 'char_col',
|
||||||
|
MEMLABEL: '',
|
||||||
|
DESC: '',
|
||||||
|
LONGDESC: '',
|
||||||
|
TYPE: '',
|
||||||
|
CLS_RULE: '',
|
||||||
|
VARNUM: 0,
|
||||||
|
LABEL: '',
|
||||||
|
FMTNAME: '',
|
||||||
|
DDTYPE: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const $dataFormats: any = {
|
||||||
|
vars: {
|
||||||
|
num_col: { format: 'best.', label: '', length: '8', type: 'num' },
|
||||||
|
char_col: { format: '$32.', label: '', length: '32', type: 'char' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergeColsRules(cols, rules, $dataFormats)
|
||||||
|
expect(merged.find((r) => r.data === 'num_col')?.sasType).toEqual('num')
|
||||||
|
expect(merged.find((r) => r.data === 'char_col')?.sasType).toEqual('char')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
import { DcValidation } from '../models/dc-validation.model'
|
import { DcValidation } from '../models/dc-validation.model'
|
||||||
import * as languages from 'numbro/dist/languages.min'
|
|
||||||
/**
|
/**
|
||||||
* Applying the numeric formats based on the browser locale/language
|
* Applying the numeric formats based on the browser locale/language
|
||||||
* So that correct decimal separators are applied.
|
* So that correct decimal separators are applied.
|
||||||
* For example european format (thousand dot, decimal comma): 1.000,00
|
* For example european format (thousand dot, decimal comma): 1.000,00
|
||||||
*
|
*
|
||||||
|
* Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro
|
||||||
|
* `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural
|
||||||
|
* decimals (Intl's default of 3 would round); `locale` replaces `culture`.
|
||||||
|
*
|
||||||
* @param rules Cell Validation rules to be updated
|
* @param rules Cell Validation rules to be updated
|
||||||
* Those rules are passed in the `columns` property Of handsontable settings.
|
* Those rules are passed in the `columns` property Of handsontable settings.
|
||||||
*/
|
*/
|
||||||
export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => {
|
export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => {
|
||||||
const lang = languages[window.navigator.language]
|
|
||||||
|
|
||||||
if (!lang) return rules
|
|
||||||
|
|
||||||
for (let rule of rules) {
|
for (let rule of rules) {
|
||||||
if (rule.type === 'numeric')
|
if (rule.type === 'numeric') {
|
||||||
rule.numericFormat = {
|
rule.numericFormat = { useGrouping: true, maximumFractionDigits: 20 }
|
||||||
pattern: '0,0',
|
rule.locale = window.navigator.language
|
||||||
culture: window.navigator.language // use this for EUR (German),
|
}
|
||||||
// more cultures available on http://numbrojs.com/languages.html
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Rounds a number like Excel's ROUND(number, num_digits):
|
||||||
|
* - rounds half away from zero (so ROUND(-0.5) === -1, ROUND(2.5) === 3)
|
||||||
|
* - supports negative `digits` (e.g. -1 rounds to the nearest ten)
|
||||||
|
*
|
||||||
|
* @param value number to round
|
||||||
|
* @param digits number of decimal places (may be negative)
|
||||||
|
* @returns the rounded number, or the original value if it is not finite
|
||||||
|
*/
|
||||||
|
export function excelRound(value: number, digits: number): number {
|
||||||
|
if (!isFinite(value) || !isFinite(digits)) return value
|
||||||
|
|
||||||
|
const factor = Math.pow(10, digits)
|
||||||
|
|
||||||
|
return (Math.sign(value) * Math.round(Math.abs(value) * factor)) / factor
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { DQRule, DQRuleTypes } from '../models/dq-rules.model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rule types whose RULE_VALUE supplies the default value inserted into a cell
|
||||||
|
* when a new row is added. Checked in priority order.
|
||||||
|
*/
|
||||||
|
const DEFAULT_VALUE_RULE_TYPES: DQRuleTypes[] = [
|
||||||
|
'NOTNULL',
|
||||||
|
'READONLY',
|
||||||
|
'HIDDEN'
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the default value for a column from DQ rules, used to pre-fill cells
|
||||||
|
* when a new row is added. Looks for the first non-empty RULE_VALUE among
|
||||||
|
* NOTNULL, READONLY and HIDDEN rules. Converts to number for numeric columns.
|
||||||
|
*
|
||||||
|
* @param colName column name to look up
|
||||||
|
* @param dqRules array of DQ rules
|
||||||
|
* @param colType column type (e.g., 'numeric', 'text')
|
||||||
|
* @returns default value (string or number) if a default-providing rule exists
|
||||||
|
* with a non-empty value, otherwise undefined
|
||||||
|
*/
|
||||||
|
export function getColumnDefault(
|
||||||
|
colName: string,
|
||||||
|
dqRules: DQRule[],
|
||||||
|
colType?: string
|
||||||
|
): string | number | undefined {
|
||||||
|
const rule = dqRules.find(
|
||||||
|
(rule: DQRule) =>
|
||||||
|
rule.BASE_COL === colName &&
|
||||||
|
DEFAULT_VALUE_RULE_TYPES.includes(rule.RULE_TYPE) &&
|
||||||
|
rule.RULE_VALUE != null &&
|
||||||
|
rule.RULE_VALUE.trim().length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!rule) return undefined
|
||||||
|
|
||||||
|
if (colType === 'numeric' && !isNaN(Number(rule.RULE_VALUE))) {
|
||||||
|
return Number(rule.RULE_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.RULE_VALUE
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { DcValidation } from '../models/dc-validation.model'
|
import { DcValidation } from '../models/dc-validation.model'
|
||||||
|
import { DQRule } from '../models/dq-rules.model'
|
||||||
|
import { getColumnDefault } from './getColumnDefault'
|
||||||
|
|
||||||
const schemaTypeMap: { [key: string]: any } = {
|
const schemaTypeMap: { [key: string]: any } = {
|
||||||
numeric: '',
|
numeric: '',
|
||||||
@@ -7,14 +9,25 @@ const schemaTypeMap: { [key: string]: any } = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema defines the default values for given types. For example when new row is added.
|
* Schema defines the default values for given types. For example when new row is added.
|
||||||
|
* Priority: NOTNULL/READONLY/HIDDEN RULE_VALUE > autocomplete first option > type default
|
||||||
*/
|
*/
|
||||||
export const getHotDataSchema = (
|
export function getHotDataSchema(
|
||||||
type: string | undefined,
|
type: string | undefined,
|
||||||
cellValidation?: DcValidation
|
cellValidation?: DcValidation,
|
||||||
): any => {
|
dqRules?: DQRule[]
|
||||||
|
): any {
|
||||||
|
// Check for a rule-supplied default (NOTNULL/READONLY/HIDDEN) first
|
||||||
|
if (dqRules && cellValidation?.data) {
|
||||||
|
const defaultValue = getColumnDefault(cellValidation.data, dqRules, type)
|
||||||
|
if (defaultValue !== undefined) {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!type) return schemaTypeMap.default
|
if (!type) return schemaTypeMap.default
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case 'dropdown':
|
||||||
case 'autocomplete': {
|
case 'autocomplete': {
|
||||||
return cellValidation && cellValidation.source
|
return cellValidation && cellValidation.source
|
||||||
? (cellValidation.source as string[] | number[])[0]
|
? (cellValidation.source as string[] | number[])[0]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { DQRule } from '../models/dq-rules.model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the NOTNULL default value for a column from DQ rules.
|
||||||
|
* Converts to number for numeric columns based on colType parameter.
|
||||||
|
*
|
||||||
|
* @param colName column name to look up
|
||||||
|
* @param dqRules array of DQ rules
|
||||||
|
* @param colType column type (e.g., 'numeric', 'text')
|
||||||
|
* @returns default value (string or number) if NOTNULL rule exists with non-empty value, otherwise undefined
|
||||||
|
*/
|
||||||
|
export function getNotNullDefault(
|
||||||
|
colName: string,
|
||||||
|
dqRules: DQRule[],
|
||||||
|
colType?: string
|
||||||
|
): string | number | undefined {
|
||||||
|
const notNullRule = dqRules.find(
|
||||||
|
(rule: DQRule) => rule.BASE_COL === colName && rule.RULE_TYPE === 'NOTNULL'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!notNullRule?.RULE_VALUE || notNullRule.RULE_VALUE.trim().length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (colType === 'numeric' && !isNaN(Number(notNullRule.RULE_VALUE))) {
|
||||||
|
return Number(notNullRule.RULE_VALUE)
|
||||||
|
}
|
||||||
|
|
||||||
|
return notNullRule.RULE_VALUE
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Checks if a value is considered empty for NOTNULL validation purposes.
|
||||||
|
* A value is empty if it's null, undefined, or a string that is blank after trimming.
|
||||||
|
*/
|
||||||
|
export function isEmpty(value: unknown): boolean {
|
||||||
|
if (value === null || value === undefined) return true
|
||||||
|
return value.toString().trim().length === 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { DcValidation } from '../models/dc-validation.model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps SAS' semantic date/time/datetime column types to the HOT 17 Intl cell
|
||||||
|
* types and attaches the locale + Intl format meta that drive their display.
|
||||||
|
*
|
||||||
|
* SAS sends a HOT-agnostic flag (`date`/`time`/`datetime`); the frontend owns
|
||||||
|
* the HOT-specific cell-type names and the locale-aware rendering:
|
||||||
|
*
|
||||||
|
* - `date` → `intl-date` (native date picker; display via Intl `dateFormat`)
|
||||||
|
* - `time` → `intl-time` (native time picker; display via Intl `timeFormat`)
|
||||||
|
* - `datetime` → `intl-datetime` (native datetime-local picker; display via `datetimeFormat`)
|
||||||
|
*
|
||||||
|
* The cell display is localized to `navigator.language` (the browser/OS locale);
|
||||||
|
* the stored/submitted value stays the raw ISO string. Second precision is kept
|
||||||
|
* for time/datetime so SAS time/datetime values are not truncated. Runs in the
|
||||||
|
* DcValidator constructor, before the rules reach Handsontable. See
|
||||||
|
* registerIntlCellTypes for the editors/validators these resolve to.
|
||||||
|
*
|
||||||
|
* @param rules Cell validation rules passed to Handsontable's `columns`.
|
||||||
|
*/
|
||||||
|
const DATE_FORMAT: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_FORMAT: Intl.DateTimeFormatOptions = {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATETIME_FORMAT: Intl.DateTimeFormatOptions = {
|
||||||
|
...DATE_FORMAT,
|
||||||
|
...TIME_FORMAT
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTL_TYPE_MAP: Record<
|
||||||
|
string,
|
||||||
|
{ type: string; meta: Record<string, unknown> }
|
||||||
|
> = {
|
||||||
|
date: { type: 'intl-date', meta: { dateFormat: DATE_FORMAT } },
|
||||||
|
time: { type: 'intl-time', meta: { timeFormat: TIME_FORMAT } },
|
||||||
|
datetime: { type: 'intl-datetime', meta: { datetimeFormat: DATETIME_FORMAT } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapIntlCellTypes = (rules: DcValidation[]): DcValidation[] => {
|
||||||
|
for (const rule of rules) {
|
||||||
|
const cfg = rule.type ? INTL_TYPE_MAP[rule.type as string] : undefined
|
||||||
|
if (!cfg) continue
|
||||||
|
|
||||||
|
rule.type = cfg.type
|
||||||
|
const meta = rule as Record<string, unknown>
|
||||||
|
meta.locale = window.navigator.language
|
||||||
|
Object.assign(meta, cfg.meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export const mergeColsRules = (
|
|||||||
if (rule && col.DESC) rule.desc = col.DESC
|
if (rule && col.DESC) rule.desc = col.DESC
|
||||||
if (rule && colFormats.length) rule.length = parseInt(colFormats.length)
|
if (rule && colFormats.length) rule.length = parseInt(colFormats.length)
|
||||||
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
|
if (rule && col.CLS_RULE) rule.clsRule = col.CLS_RULE
|
||||||
|
if (rule && colFormats?.type) rule.sasType = colFormats.type
|
||||||
}
|
}
|
||||||
|
|
||||||
return rules
|
return rules
|
||||||
|
|||||||
@@ -22,3 +22,26 @@ export function specialMissingNumericValidator(
|
|||||||
if (callback) callback(valid)
|
if (callback) callback(valid)
|
||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DATETIME_REGEX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validator for the custom `intl-datetime` cell type. Expects SAS' ISO datetime
|
||||||
|
* form `YYYY-MM-DD HH:mm:ss`. Mirrors HOT's intl validators by honouring
|
||||||
|
* `this.allowEmpty` for blank values. Bound by HOT to the cell properties, so
|
||||||
|
* `this` carries `allowEmpty`.
|
||||||
|
*/
|
||||||
|
export function datetimeValidator(
|
||||||
|
this: { allowEmpty?: boolean } | void,
|
||||||
|
value: any,
|
||||||
|
callback: (valid: boolean) => void
|
||||||
|
) {
|
||||||
|
const allowEmpty = (this as { allowEmpty?: boolean })?.allowEmpty ?? true
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
callback(allowEmpty)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(typeof value === 'string' && DATETIME_REGEX.test(value))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import Handsontable from 'handsontable'
|
||||||
|
import { MenuItemConfig } from 'handsontable/plugins/contextMenu'
|
||||||
|
|
||||||
|
export type HotExportFormat = 'csv' | 'xlsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cell types whose values must be exported as raw stored strings rather than
|
||||||
|
* converted to native Excel date/time cells. SAS raw data is the canonical
|
||||||
|
* representation, so dates/times are exported exactly as the grid stores them.
|
||||||
|
* `numeric` is intentionally NOT listed — it stays a native Excel number.
|
||||||
|
*/
|
||||||
|
const RAW_EXPORT_TYPES = new Set([
|
||||||
|
'date',
|
||||||
|
'time',
|
||||||
|
'datetime',
|
||||||
|
'intl-date',
|
||||||
|
'intl-time',
|
||||||
|
'intl-datetime'
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side export of a Handsontable grid's current view via the built-in
|
||||||
|
* `exportFile` plugin. Exports the current selection if one exists, otherwise
|
||||||
|
* the whole grid. This is a "what you see" convenience export and is distinct
|
||||||
|
* from any server-side (SAS) download.
|
||||||
|
*
|
||||||
|
* @param hot Live Handsontable instance.
|
||||||
|
* @param format `'csv'` or `'xlsx'`.
|
||||||
|
* @param filename File name (without extension).
|
||||||
|
* @param skipLeadingCols Number of leading UI/housekeeping columns to exclude
|
||||||
|
* (e.g. the editor's `Delete?` column at index 0).
|
||||||
|
*/
|
||||||
|
export async function exportGrid(
|
||||||
|
hot: Handsontable,
|
||||||
|
format: HotExportFormat,
|
||||||
|
filename: string,
|
||||||
|
skipLeadingCols = 0
|
||||||
|
): Promise<void> {
|
||||||
|
const plugin = hot.getPlugin('exportFile')
|
||||||
|
|
||||||
|
const opts: Record<string, any> = {
|
||||||
|
filename: filename || 'export',
|
||||||
|
colHeaders: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection-aware, contiguous range (visual indexes). Hidden columns are
|
||||||
|
// excluded automatically (exportHiddenColumns defaults to false).
|
||||||
|
const sel = hot.getSelectedRangeLast()
|
||||||
|
const lastRow = hot.countRows() - 1
|
||||||
|
const lastCol = hot.countCols() - 1
|
||||||
|
|
||||||
|
// Mirror HOT's own export item: only honor a selection that spans more than
|
||||||
|
// one cell. A right-click places a single-cell cursor, and the corner click
|
||||||
|
// is select-all (negative coords) — both mean "export the whole table".
|
||||||
|
const isCornerSelectAll = !!sel && sel.from.row < 0 && sel.from.col < 0
|
||||||
|
if (sel && !sel.isSingleCell() && !isCornerSelectAll) {
|
||||||
|
const top = Math.max(0, Math.min(sel.from.row, sel.to.row))
|
||||||
|
const left = Math.max(0, Math.min(sel.from.col, sel.to.col))
|
||||||
|
const bottom = Math.max(sel.from.row, sel.to.row)
|
||||||
|
const right = Math.max(sel.from.col, sel.to.col)
|
||||||
|
opts['range'] = [top, Math.max(left, skipLeadingCols), bottom, right]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts['range'] && skipLeadingCols > 0) {
|
||||||
|
opts['range'] = [0, skipLeadingCols, lastRow, lastCol]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
// CSV reads raw stored values (getDataAtCell) — no type conversion needed.
|
||||||
|
plugin.downloadFile('csv', opts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// xlsx: temporarily override cell meta during the export's single
|
||||||
|
// getCellMeta() pass, then restore (HOT returns cached meta objects).
|
||||||
|
// - date/time/datetime types -> 'text' so values export raw, not as native
|
||||||
|
// Excel dates.
|
||||||
|
// - drop color/highlight: clear classNames and readOnly so the exporter
|
||||||
|
// doesn't bake in theme/validation/read-only fills. Cell classes carry no
|
||||||
|
// meaning in the exported file.
|
||||||
|
const restore: Array<{
|
||||||
|
meta: any
|
||||||
|
type: any
|
||||||
|
className: any
|
||||||
|
readOnly: any
|
||||||
|
}> = []
|
||||||
|
const seen = new Set<any>()
|
||||||
|
const hook = (_row: number, _col: number, cp: any): void => {
|
||||||
|
if (seen.has(cp)) return
|
||||||
|
seen.add(cp)
|
||||||
|
|
||||||
|
const needsTypeFix = RAW_EXPORT_TYPES.has(cp.type)
|
||||||
|
const needsClassFix =
|
||||||
|
typeof cp.className === 'string' && cp.className !== ''
|
||||||
|
const needsReadOnlyFix = cp.readOnly === true
|
||||||
|
|
||||||
|
if (!needsTypeFix && !needsClassFix && !needsReadOnlyFix) return
|
||||||
|
|
||||||
|
restore.push({
|
||||||
|
meta: cp,
|
||||||
|
type: cp.type,
|
||||||
|
className: cp.className,
|
||||||
|
readOnly: cp.readOnly
|
||||||
|
})
|
||||||
|
|
||||||
|
if (needsTypeFix) cp.type = 'text'
|
||||||
|
if (needsClassFix) cp.className = ''
|
||||||
|
if (needsReadOnlyFix) cp.readOnly = false
|
||||||
|
}
|
||||||
|
|
||||||
|
hot.addHook('afterGetCellMeta', hook)
|
||||||
|
try {
|
||||||
|
const mod: any = await import('exceljs')
|
||||||
|
const ExcelJS = mod.default ?? mod
|
||||||
|
await plugin.downloadFileAsync('xlsx', { ...opts, engine: ExcelJS })
|
||||||
|
} finally {
|
||||||
|
hot.removeHook('afterGetCellMeta', hook)
|
||||||
|
restore.forEach(({ meta, type, className, readOnly }) => {
|
||||||
|
meta.type = type
|
||||||
|
meta.className = className
|
||||||
|
meta.readOnly = readOnly
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a reusable `export_file` context-menu item (CSV / Excel submenu) that
|
||||||
|
* exports the given grid via {@link exportGrid}. Add it to any HOT context-menu
|
||||||
|
* `items` map under the `export_file` key.
|
||||||
|
*
|
||||||
|
* @param getFilename Returns the export file name at click time.
|
||||||
|
* @param skipLeadingCols Leading UI columns to exclude (see {@link exportGrid}).
|
||||||
|
*/
|
||||||
|
export function buildExportMenuItem(
|
||||||
|
getFilename: () => string,
|
||||||
|
skipLeadingCols = 0
|
||||||
|
): MenuItemConfig {
|
||||||
|
// HOT invokes context-menu callbacks with `this` bound to the clicked grid
|
||||||
|
// instance, so the item works uniformly even for components that host
|
||||||
|
// multiple grids (e.g. viewboxes).
|
||||||
|
const makeCb = (format: HotExportFormat) =>
|
||||||
|
function (this: Handsontable): void {
|
||||||
|
void exportGrid(this, format, getFilename(), skipLeadingCols)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Export',
|
||||||
|
// The exportFile plugin registers a predefined item under the same
|
||||||
|
// `export_file` key whose `hidden()` returns true unless an `exportFile`
|
||||||
|
// setting exists. HOT shallow-merges our config over it, so we must
|
||||||
|
// explicitly override `hidden` to keep the item visible.
|
||||||
|
hidden: false,
|
||||||
|
submenu: {
|
||||||
|
// Submenu keys use the documented "parent_key:sub_key" form. The array
|
||||||
|
// form is what HOT's own exportFile plugin uses at runtime; cast to
|
||||||
|
// satisfy the (stricter) MenuConfig typing.
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'export_file:csv',
|
||||||
|
name: 'CSV',
|
||||||
|
callback: makeCb('csv')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'export_file:xlsx',
|
||||||
|
name: 'Excel (.xlsx)',
|
||||||
|
callback: makeCb('xlsx')
|
||||||
|
}
|
||||||
|
] as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ import { DirectivesModule } from '../directives/directives.module'
|
|||||||
import { DatasetInfoComponent } from './dataset-info/dataset-info.component'
|
import { DatasetInfoComponent } from './dataset-info/dataset-info.component'
|
||||||
import { ContactLinkComponent } from './contact-link/contact-link.component'
|
import { ContactLinkComponent } from './contact-link/contact-link.component'
|
||||||
import { ExcelPasswordModalComponent } from './excel-password-modal/excel-password-modal.component'
|
import { ExcelPasswordModalComponent } from './excel-password-modal/excel-password-modal.component'
|
||||||
|
import { ConfirmModalComponent } from './confirm-modal/confirm-modal.component'
|
||||||
|
import { BulkValidationModalComponent } from './bulk-validation-modal/bulk-validation-modal.component'
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -32,7 +34,9 @@ import { ExcelPasswordModalComponent } from './excel-password-modal/excel-passwo
|
|||||||
TermsComponent,
|
TermsComponent,
|
||||||
DatasetInfoComponent,
|
DatasetInfoComponent,
|
||||||
ContactLinkComponent,
|
ContactLinkComponent,
|
||||||
ExcelPasswordModalComponent
|
ExcelPasswordModalComponent,
|
||||||
|
ConfirmModalComponent,
|
||||||
|
BulkValidationModalComponent
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
LoadingIndicatorComponent,
|
LoadingIndicatorComponent,
|
||||||
@@ -42,7 +46,9 @@ import { ExcelPasswordModalComponent } from './excel-password-modal/excel-passwo
|
|||||||
TermsComponent,
|
TermsComponent,
|
||||||
DatasetInfoComponent,
|
DatasetInfoComponent,
|
||||||
ContactLinkComponent,
|
ContactLinkComponent,
|
||||||
ExcelPasswordModalComponent
|
ExcelPasswordModalComponent,
|
||||||
|
ConfirmModalComponent,
|
||||||
|
BulkValidationModalComponent
|
||||||
],
|
],
|
||||||
providers: [UserService, AlertsService]
|
providers: [UserService, AlertsService]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<clr-dropdown>
|
<clr-dropdown>
|
||||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||||
{{ getSubPage() }}
|
{{ getSubPage() }}
|
||||||
<clr-icon shape="caret down"></clr-icon>
|
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||||
</button>
|
</button>
|
||||||
<clr-dropdown-menu *clrIfOpen>
|
<clr-dropdown-menu *clrIfOpen>
|
||||||
<a
|
<a
|
||||||
@@ -76,7 +76,9 @@
|
|||||||
class="user-nav-btn"
|
class="user-nav-btn"
|
||||||
clrTabLink
|
clrTabLink
|
||||||
>
|
>
|
||||||
<p class="zero-margin"><clr-icon shape="user"></clr-icon> Users</p>
|
<p class="zero-margin">
|
||||||
|
<clr-icon aria-hidden="true" shape="user"></clr-icon> Users
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<clr-tab-content> </clr-tab-content>
|
<clr-tab-content> </clr-tab-content>
|
||||||
</clr-tab>
|
</clr-tab>
|
||||||
@@ -87,7 +89,9 @@
|
|||||||
class="user-nav-btn"
|
class="user-nav-btn"
|
||||||
clrTabLink
|
clrTabLink
|
||||||
>
|
>
|
||||||
<p class="zero-margin"><clr-icon shape="users"></clr-icon> Groups</p>
|
<p class="zero-margin">
|
||||||
|
<clr-icon aria-hidden="true" shape="users"></clr-icon> Groups
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/groups')">
|
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/groups')">
|
||||||
</clr-tab-content>
|
</clr-tab-content>
|
||||||
@@ -100,7 +104,7 @@
|
|||||||
clrTabLink
|
clrTabLink
|
||||||
>
|
>
|
||||||
<p class="zero-margin">
|
<p class="zero-margin">
|
||||||
<clr-icon shape="blocks-group"></clr-icon> Roles
|
<clr-icon aria-hidden="true" shape="blocks-group"></clr-icon> Roles
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/roles')">
|
<clr-tab-content *clrIfActive="isMainRoute('/view/usernav/roles')">
|
||||||
@@ -115,7 +119,7 @@
|
|||||||
<clr-dropdown>
|
<clr-dropdown>
|
||||||
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
<button class="dropdown-toggle btn btn-primary" clrDropdownTrigger>
|
||||||
{{ getSubPage() }}
|
{{ getSubPage() }}
|
||||||
<clr-icon shape="caret down"></clr-icon>
|
<clr-icon aria-hidden="true" shape="caret down"></clr-icon>
|
||||||
</button>
|
</button>
|
||||||
<clr-dropdown-menu *clrIfOpen>
|
<clr-dropdown-menu *clrIfOpen>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -80,15 +80,13 @@ export class SidebarComponent implements OnInit {
|
|||||||
public resizeStart() {
|
public resizeStart() {
|
||||||
this.resizing = true
|
this.resizing = true
|
||||||
|
|
||||||
let body = document.getElementsByTagName('body')[0]
|
document.body.classList.add('select-none')
|
||||||
body.style.cssText = 'user-select: none'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public resizeEnd() {
|
public resizeEnd() {
|
||||||
this.resizing = false
|
this.resizing = false
|
||||||
|
|
||||||
let body = document.getElementsByTagName('body')[0]
|
document.body.classList.remove('select-none')
|
||||||
body.style.cssText = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:mousemove', ['$event'])
|
@HostListener('document:mousemove', ['$event'])
|
||||||
|
|||||||
@@ -375,38 +375,30 @@ export class SpreadsheetUtil {
|
|||||||
fileType: string
|
fileType: string
|
||||||
): Promise<ParseResult> {
|
): Promise<ParseResult> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.licenceState.value.submit_rows_limit !== Infinity) {
|
if (!this.licenceState.value.fileUpload) {
|
||||||
uploader.queue.pop()
|
uploader.queue.pop()
|
||||||
return reject(
|
return reject(
|
||||||
'Excel files only. To unlock CSV uploads, please contact support@datacontroller.io'
|
'File uploads are not enabled for this licence. Please contact support@datacontroller.io'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parseParams.encoding === 'WLATIN1') {
|
if (parseParams.encoding !== 'WLATIN1') return resolve({ uploader })
|
||||||
let reader = new FileReader()
|
|
||||||
const self = this
|
|
||||||
// Closure to capture the file information.
|
|
||||||
reader.onload = (theFile: any) => {
|
|
||||||
let encoded = iconv.decode(
|
|
||||||
Buffer.from(theFile.target.result),
|
|
||||||
'CP-1252'
|
|
||||||
)
|
|
||||||
let blob = new Blob([encoded], { type: fileType })
|
|
||||||
let encodedFile: File = blobToFile(blob, parseParams.file.name)
|
|
||||||
uploader.queue.pop()
|
|
||||||
uploader.addToQueue([encodedFile])
|
|
||||||
|
|
||||||
return resolve({
|
const reader = new FileReader()
|
||||||
uploader
|
reader.onload = (theFile) => {
|
||||||
})
|
if (!theFile.target?.result) return resolve({ uploader })
|
||||||
}
|
|
||||||
|
|
||||||
reader.readAsArrayBuffer(parseParams.file)
|
const text = theFile.target.result as string
|
||||||
} else {
|
const encoded = iconv.encode(text, 'CP-1252')
|
||||||
return resolve({
|
const blob = new Blob([encoded], { type: fileType })
|
||||||
uploader
|
const encodedFile: File = blobToFile(blob, parseParams.file.name)
|
||||||
})
|
uploader.queue.pop()
|
||||||
|
uploader.addToQueue([encodedFile])
|
||||||
|
|
||||||
|
return resolve({ uploader })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reader.readAsText(parseParams.file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +503,21 @@ export class SpreadsheetUtil {
|
|||||||
return resolve(XLSX.read(data, opts))
|
return resolve(XLSX.read(data, opts))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TEMPORARILY DISABLED: Web Worker for XLSX parsing
|
||||||
|
// Worker is disabled because Angular/webpack bundles it as a separate chunk
|
||||||
|
// with a numeric filename (e.g., 411.hash.js). In SAS9/Viya streaming
|
||||||
|
// environments, all JS files need to be served through SASJobExecution
|
||||||
|
// with _program= parameter, but our post-build processor can't reliably
|
||||||
|
// find and replace the worker chunk reference in the minified output.
|
||||||
|
// FIX: Add "namedChunks": true to production config in angular.json
|
||||||
|
// (under projects.datacontroller.architect.build.configurations.production)
|
||||||
|
// This will output worker as "spreadsheet-worker.hash.js" instead of
|
||||||
|
// numeric ID, making it findable by post-processor.
|
||||||
|
// Trade-off: UI may briefly freeze when parsing large Excel files.
|
||||||
|
|
||||||
|
return resolve(XLSX.read(data, opts))
|
||||||
|
|
||||||
|
/*
|
||||||
if (typeof Worker === 'undefined') {
|
if (typeof Worker === 'undefined') {
|
||||||
console.info(
|
console.info(
|
||||||
'Not using worker to parse the XLSX - no Worker available in this environment'
|
'Not using worker to parse the XLSX - no Worker available in this environment'
|
||||||
@@ -551,6 +558,7 @@ export class SpreadsheetUtil {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
return resolve(XLSX.read(data, opts))
|
return resolve(XLSX.read(data, opts))
|
||||||
}, 600 * 1000) // 10 minutes
|
}, 600 * 1000) // 10 minutes
|
||||||
|
*/
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,6 +816,7 @@ export class SpreadsheetUtil {
|
|||||||
data.forEach((row: any[]) => {
|
data.forEach((row: any[]) => {
|
||||||
dateCols.forEach((element) => {
|
dateCols.forEach((element) => {
|
||||||
const obj = row[element]
|
const obj = row[element]
|
||||||
|
if (!obj || obj.v == null) return
|
||||||
if (isStringNumber(obj.v)) {
|
if (isStringNumber(obj.v)) {
|
||||||
const date = excelDateToJSDate(Number(obj.v))
|
const date = excelDateToJSDate(Number(obj.v))
|
||||||
|
|
||||||
@@ -839,6 +848,7 @@ export class SpreadsheetUtil {
|
|||||||
data.forEach((row: any[]) => {
|
data.forEach((row: any[]) => {
|
||||||
timeCols.forEach((element) => {
|
timeCols.forEach((element) => {
|
||||||
const obj = row[element]
|
const obj = row[element]
|
||||||
|
if (!obj || obj.v == null) return
|
||||||
if (
|
if (
|
||||||
isStringNumber(obj.v) ||
|
isStringNumber(obj.v) ||
|
||||||
isStringDecimal(obj.v) ||
|
isStringDecimal(obj.v) ||
|
||||||
@@ -862,6 +872,7 @@ export class SpreadsheetUtil {
|
|||||||
data.forEach((row: any[]) => {
|
data.forEach((row: any[]) => {
|
||||||
dateTimeCols.forEach((element) => {
|
dateTimeCols.forEach((element) => {
|
||||||
const obj = row[element]
|
const obj = row[element]
|
||||||
|
if (!obj || obj.v == null) return
|
||||||
if (isStringNumber(obj.v) || isStringDecimal(obj.v)) {
|
if (isStringNumber(obj.v) || isStringDecimal(obj.v)) {
|
||||||
const date = excelDateToJSDate(Number(obj.v))
|
const date = excelDateToJSDate(Number(obj.v))
|
||||||
obj.v = dateFormat(date) + ' ' + dateToUtcTime(date)
|
obj.v = dateFormat(date) + ' ' + dateToUtcTime(date)
|
||||||
@@ -895,6 +906,7 @@ export class SpreadsheetUtil {
|
|||||||
data.forEach((row: any[]) => {
|
data.forEach((row: any[]) => {
|
||||||
xlRuleCols.forEach((element: any) => {
|
xlRuleCols.forEach((element: any) => {
|
||||||
const obj = row[element.index]
|
const obj = row[element.index]
|
||||||
|
if (!obj) return
|
||||||
if (element.XL_RULE === 'FORMULA') {
|
if (element.XL_RULE === 'FORMULA') {
|
||||||
if ('f' in obj) {
|
if ('f' in obj) {
|
||||||
if (obj['t'] === 'n') {
|
if (obj['t'] === 'n') {
|
||||||
@@ -904,7 +916,7 @@ export class SpreadsheetUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row[element] = obj
|
row[element.index] = obj
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@
|
|||||||
[class.hidden]="debugLogs.length === 0"
|
[class.hidden]="debugLogs.length === 0"
|
||||||
>{{ debugLogs.length }}</span
|
>{{ debugLogs.length }}</span
|
||||||
>
|
>
|
||||||
<clr-icon *ngIf="!isViya" shape="caret down"></clr-icon>
|
<clr-icon
|
||||||
|
*ngIf="!isViya"
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="caret down"
|
||||||
|
></clr-icon>
|
||||||
</button>
|
</button>
|
||||||
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
|
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
|
||||||
<div #dropdownItemDebug class="debug-switch-item" clrDropdownItem>
|
<div #dropdownItemDebug class="debug-switch-item" clrDropdownItem>
|
||||||
@@ -83,7 +87,11 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="..." (click)="logout($event)" clrDropdownItem>
|
<a href="..." (click)="logout($event)" clrDropdownItem>
|
||||||
<span>Log Out</span>
|
<span>Log Out</span>
|
||||||
<clr-icon class="clr-logout" shape="logout"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
class="clr-logout"
|
||||||
|
shape="logout"
|
||||||
|
></clr-icon>
|
||||||
</a>
|
</a>
|
||||||
<div class="copyRight">
|
<div class="copyRight">
|
||||||
<span>v{{ commitVer }}</span>
|
<span>v{{ commitVer }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Strips characters that could cause SAS macro injection (& % ;).
|
||||||
|
*/
|
||||||
|
export function sanitiseForSas(input: string): string {
|
||||||
|
return input.replace(/[%&;]/g, '')
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||||
>
|
>
|
||||||
<clr-icon shape="grid-view" size="19"></clr-icon>
|
<clr-icon aria-hidden="true" shape="grid-view" size="19"></clr-icon>
|
||||||
<span class="tooltip-content">Snap viewboxes to grid</span>
|
<span class="tooltip-content">Snap viewboxes to grid</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||||
>
|
>
|
||||||
<clr-icon shape="minus" size="19"></clr-icon>
|
<clr-icon aria-hidden="true" shape="minus" size="19"></clr-icon>
|
||||||
<span class="tooltip-content">Minimize all viewboxes</span>
|
<span class="tooltip-content">Minimize all viewboxes</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -70,7 +70,11 @@
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||||
>
|
>
|
||||||
<clr-icon shape="window-restore" size="19"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="window-restore"
|
||||||
|
size="19"
|
||||||
|
></clr-icon>
|
||||||
<span class="tooltip-content">Restore all viewboxes</span>
|
<span class="tooltip-content">Restore all viewboxes</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +109,11 @@
|
|||||||
class="tooltip tooltip-md tooltip-bottom-left"
|
class="tooltip tooltip-md tooltip-bottom-left"
|
||||||
[class.disabled]="viewbox.x === 0 && viewbox.y === 0"
|
[class.disabled]="viewbox.x === 0 && viewbox.y === 0"
|
||||||
>
|
>
|
||||||
<clr-icon shape="cursor-move" size="13"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="cursor-move"
|
||||||
|
size="13"
|
||||||
|
></clr-icon>
|
||||||
<span class="tooltip-content">Reset position and size</span>
|
<span class="tooltip-content">Reset position and size</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -116,7 +124,7 @@
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||||
>
|
>
|
||||||
<clr-icon shape="minus" size="19"></clr-icon>
|
<clr-icon aria-hidden="true" shape="minus" size="19"></clr-icon>
|
||||||
<span class="tooltip-content">Minimize viewbox</span>
|
<span class="tooltip-content">Minimize viewbox</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -127,7 +135,11 @@
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||||
>
|
>
|
||||||
<clr-icon shape="window-restore" size="19"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="window-restore"
|
||||||
|
size="19"
|
||||||
|
></clr-icon>
|
||||||
<span class="tooltip-content">Restore viewbox</span>
|
<span class="tooltip-content">Restore viewbox</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -137,7 +149,7 @@
|
|||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
class="tooltip tooltip-md tooltip-bottom-left ml-3"
|
||||||
>
|
>
|
||||||
<clr-icon shape="times" size="19"></clr-icon>
|
<clr-icon aria-hidden="true" shape="times" size="19"></clr-icon>
|
||||||
<span class="tooltip-content">Close viewbox</span>
|
<span class="tooltip-content">Close viewbox</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,6 +238,7 @@
|
|||||||
}}
|
}}
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="hotColumns.headerPks.includes(column)"
|
*ngIf="hotColumns.headerPks.includes(column)"
|
||||||
|
aria-hidden="true"
|
||||||
shape="key"
|
shape="key"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
</span>
|
</span>
|
||||||
@@ -233,6 +246,7 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="!hotColumns.headerPks.includes(column)"
|
*ngIf="!hotColumns.headerPks.includes(column)"
|
||||||
(click)="onColRemove(column)"
|
(click)="onColRemove(column)"
|
||||||
|
aria-label="Remove column"
|
||||||
shape="trash"
|
shape="trash"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
@@ -284,15 +298,25 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="!viewbox.collapsed"
|
*ngIf="!viewbox.collapsed"
|
||||||
(click)="collapse(viewbox)"
|
(click)="collapse(viewbox)"
|
||||||
|
aria-label="Collapse"
|
||||||
shape="angle top"
|
shape="angle top"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="viewbox.collapsed"
|
*ngIf="viewbox.collapsed"
|
||||||
(click)="expand(viewbox)"
|
(click)="expand(viewbox)"
|
||||||
|
aria-label="Expand"
|
||||||
shape="angle down"
|
shape="angle down"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<clr-icon (click)="minimize(viewbox)" shape="minus"></clr-icon>
|
<clr-icon
|
||||||
<clr-icon (click)="close(viewbox)" shape="times"></clr-icon>
|
(click)="minimize(viewbox)"
|
||||||
|
aria-label="Minimize"
|
||||||
|
shape="minus"
|
||||||
|
></clr-icon>
|
||||||
|
<clr-icon
|
||||||
|
(click)="close(viewbox)"
|
||||||
|
aria-label="Close"
|
||||||
|
shape="times"
|
||||||
|
></clr-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -320,6 +344,7 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="!viewbox.searchLoading"
|
*ngIf="!viewbox.searchLoading"
|
||||||
(click)="searchTable(searchEl, viewbox)"
|
(click)="searchTable(searchEl, viewbox)"
|
||||||
|
aria-label="Search"
|
||||||
shape="search"
|
shape="search"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<span *ngIf="viewbox.searchLoading" class="spinner spinner-inline">
|
<span *ngIf="viewbox.searchLoading" class="spinner spinner-inline">
|
||||||
@@ -347,6 +372,7 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="!viewbox.loadingData"
|
*ngIf="!viewbox.loadingData"
|
||||||
(click)="reloadTableData(viewbox); searchEl.value = ''"
|
(click)="reloadTableData(viewbox); searchEl.value = ''"
|
||||||
|
aria-label="Reload data"
|
||||||
shape="refresh"
|
shape="refresh"
|
||||||
class="click-icon"
|
class="click-icon"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
@@ -357,6 +383,7 @@
|
|||||||
class="tooltip tooltip-lg tooltip-bottom-left ml-5"
|
class="tooltip tooltip-lg tooltip-bottom-left ml-5"
|
||||||
>
|
>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
shape="filter"
|
shape="filter"
|
||||||
[class.filter-active]="viewbox.filter_pk !== '0'"
|
[class.filter-active]="viewbox.filter_pk !== '0'"
|
||||||
class="click-icon"
|
class="click-icon"
|
||||||
@@ -368,6 +395,7 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
(click)="openTableEdit(viewbox)"
|
(click)="openTableEdit(viewbox)"
|
||||||
[class.disabled]="!tableEditExists(viewbox)"
|
[class.disabled]="!tableEditExists(viewbox)"
|
||||||
|
aria-label="Edit table"
|
||||||
shape="pencil"
|
shape="pencil"
|
||||||
class="click-icon ml-5"
|
class="click-icon ml-5"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { AutocompleteComponent } from 'src/app/shared/autocomplete/autocomplete.
|
|||||||
import { LibraryClickEmitter } from 'src/app/shared/dc-tree/models/LibraryClickEmitter'
|
import { LibraryClickEmitter } from 'src/app/shared/dc-tree/models/LibraryClickEmitter'
|
||||||
import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitter'
|
import { TableClickEmitter } from 'src/app/shared/dc-tree/models/TableClickEmitter'
|
||||||
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules'
|
import { mergeColsRules } from 'src/app/shared/dc-validator/utils/mergeColsRules'
|
||||||
|
import { buildExportMenuItem } from 'src/app/shared/hot-export/hot-export.util'
|
||||||
import { globals, initFilter } from 'src/app/_globals'
|
import { globals, initFilter } from 'src/app/_globals'
|
||||||
import { ViewboxHotTable } from './models/viewbox-hot-table.model'
|
import { ViewboxHotTable } from './models/viewbox-hot-table.model'
|
||||||
import { ViewboxTable } from './models/viewbox-table.model'
|
import { ViewboxTable } from './models/viewbox-table.model'
|
||||||
@@ -526,7 +527,7 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
const viewboxTable = this.viewboxTables[viewboxTableIndex]
|
const viewboxTable = this.viewboxTables[viewboxTableIndex]
|
||||||
const calculatedHeight = this.calculateTableHeight(viewbox)
|
const calculatedHeight = this.calculateTableHeight(viewbox)
|
||||||
|
|
||||||
// HOT v16 settings - data will be loaded manually after initialization
|
// HOT v17 settings - data will be loaded manually after initialization
|
||||||
const settings: Handsontable.GridSettings = {
|
const settings: Handsontable.GridSettings = {
|
||||||
colHeaders: viewboxTable.hotTable.colHeaders,
|
colHeaders: viewboxTable.hotTable.colHeaders,
|
||||||
columns: viewboxTable.hotTable.columns,
|
columns: viewboxTable.hotTable.columns,
|
||||||
@@ -534,7 +535,15 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
modifyColWidth: this.maxWidthCheker,
|
modifyColWidth: this.maxWidthCheker,
|
||||||
copyPaste: viewboxTable.hotTable.copyPaste,
|
copyPaste: viewboxTable.hotTable.copyPaste,
|
||||||
contextMenu: viewboxTable.hotTable.contextMenu,
|
// Built here (not in hotTableDefault) because that template is cloneDeep'd,
|
||||||
|
// which would strip the export callbacks. Client-side view export.
|
||||||
|
contextMenu: {
|
||||||
|
items: {
|
||||||
|
copy_with_column_headers: {},
|
||||||
|
copy_column_headers_only: {},
|
||||||
|
export_file: buildExportMenuItem(() => 'viewbox')
|
||||||
|
}
|
||||||
|
},
|
||||||
multiColumnSorting: true,
|
multiColumnSorting: true,
|
||||||
viewportRowRenderingOffset: 50,
|
viewportRowRenderingOffset: 50,
|
||||||
filters: true,
|
filters: true,
|
||||||
@@ -544,7 +553,8 @@ export class ViewboxesComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
maxRows: viewboxTable.hotTable.maxRows || Infinity,
|
maxRows: viewboxTable.hotTable.maxRows || Infinity,
|
||||||
manualColumnResize: true,
|
manualColumnResize: true,
|
||||||
rowHeaders: true,
|
rowHeaders: true,
|
||||||
licenseKey: viewboxTable.hotTable.licenseKey
|
licenseKey: viewboxTable.hotTable.licenseKey,
|
||||||
|
theme: 'ht-theme-classic'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force a new object reference to trigger change detection
|
// Force a new object reference to trigger change detection
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
* We use normal version of the XLSX (SheetJS)
|
* We use normal version of the XLSX (SheetJS)
|
||||||
* Because at the moment "@sheet/crypto" can't work in the Web Worker environment
|
* Because at the moment "@sheet/crypto" can't work in the Web Worker environment
|
||||||
* Because of the missing "global" variable.
|
* Because of the missing "global" variable.
|
||||||
|
*
|
||||||
|
* Version bumped to v0.20.3 (`libraries/xlsx-0.20.3.tgz`)
|
||||||
|
* @see https://cdn.sheetjs.com/
|
||||||
*/
|
*/
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="content-area">
|
<main class="content-area">
|
||||||
<div class="clr-col-md-12 card">
|
<div class="clr-col-md-12 card">
|
||||||
<div
|
<div
|
||||||
*ngIf="!loaded"
|
*ngIf="!loaded"
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
<hot-table
|
<hot-table
|
||||||
id="hotTable"
|
id="hotTable"
|
||||||
[data]="hotTable.data"
|
[data]="hotTable.data"
|
||||||
[settings]="hotTableSettings"
|
[settings]="hotTableSettings()"
|
||||||
aria-label="Staged data table"
|
aria-label="Staged data table"
|
||||||
>
|
>
|
||||||
<!--[licenseKey]=null-->
|
<!--[licenseKey]=null-->
|
||||||
@@ -137,4 +137,4 @@
|
|||||||
<div class="card-footer d-flex justify-content-center"></div>
|
<div class="card-footer d-flex justify-content-center"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
ViewEncapsulation,
|
ViewEncapsulation,
|
||||||
ViewChild,
|
AfterViewInit,
|
||||||
AfterViewInit
|
signal
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { SasStoreService } from '../services/sas-store.service'
|
import { SasStoreService } from '../services/sas-store.service'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
@@ -63,7 +63,12 @@ export class StageComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get hotTableSettings(): Handsontable.GridSettings {
|
// Stable settings reference so the HOT v17 wrapper doesn't re-run
|
||||||
|
// updateSettings on every change-detection tick. Updated only when the data/
|
||||||
|
// columns or license change.
|
||||||
|
hotTableSettings = signal<Handsontable.GridSettings>(this.buildHotSettings())
|
||||||
|
|
||||||
|
private buildHotSettings(): Handsontable.GridSettings {
|
||||||
return {
|
return {
|
||||||
...this.hotTable.settings,
|
...this.hotTable.settings,
|
||||||
colHeaders: this.hotTable.colHeaders,
|
colHeaders: this.hotTable.colHeaders,
|
||||||
@@ -75,7 +80,8 @@ export class StageComponent implements OnInit, AfterViewInit {
|
|||||||
afterInit: this.hotTable.afterInit,
|
afterInit: this.hotTable.afterInit,
|
||||||
stretchH: 'all',
|
stretchH: 'all',
|
||||||
cells: this.hotTable.cells,
|
cells: this.hotTable.cells,
|
||||||
className: 'htDark'
|
className: 'htDark',
|
||||||
|
theme: 'ht-theme-classic'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +137,7 @@ export class StageComponent implements OnInit, AfterViewInit {
|
|||||||
this.licenceService.hot_license_key.subscribe(
|
this.licenceService.hot_license_key.subscribe(
|
||||||
(hot_license_key: string | undefined) => {
|
(hot_license_key: string | undefined) => {
|
||||||
this.hotTable.licenseKey = hot_license_key
|
this.hotTable.licenseKey = hot_license_key
|
||||||
|
this.hotTableSettings.set(this.buildHotSettings())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,6 +197,7 @@ export class StageComponent implements OnInit, AfterViewInit {
|
|||||||
this.hotTable.colHeaders = colHeaders
|
this.hotTable.colHeaders = colHeaders
|
||||||
this.hotTable.columns = columns
|
this.hotTable.columns = columns
|
||||||
this.hotTable.cells = cells
|
this.hotTable.cells = cells
|
||||||
|
this.hotTableSettings.set(this.buildHotSettings())
|
||||||
|
|
||||||
this.loaded = true
|
this.loaded = true
|
||||||
this.setFocus()
|
this.setFocus()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const routes: Routes = [{ path: ':tableId', component: StageComponent }]
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
ClarityModule,
|
ClarityModule,
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
HotTableModule.forRoot()
|
HotTableModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class StageModule {}
|
export class StageModule {}
|
||||||
|
|||||||
@@ -236,7 +236,36 @@
|
|||||||
<div class="admin-action">
|
<div class="admin-action">
|
||||||
Download Configuration
|
Download Configuration
|
||||||
|
|
||||||
<button (click)="downloadConfiguration()" class="btn btn-info btn-sm">
|
<div class="libref-group">
|
||||||
|
<clr-tooltip class="libref-tooltip">
|
||||||
|
<label clrTooltipTrigger class="libref-label">
|
||||||
|
Target DC Library
|
||||||
|
<cds-icon shape="info-circle" size="16"></cds-icon>
|
||||||
|
</label>
|
||||||
|
<clr-tooltip-content
|
||||||
|
clrPosition="bottom-left"
|
||||||
|
clrSize="md"
|
||||||
|
*clrIfOpen
|
||||||
|
>
|
||||||
|
Enter the target DC library and the downloaded files will
|
||||||
|
contain this, instead of the original.
|
||||||
|
</clr-tooltip-content>
|
||||||
|
</clr-tooltip>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="clr-input libref-input"
|
||||||
|
maxlength="8"
|
||||||
|
[ngModel]="dcLib"
|
||||||
|
(ngModelChange)="targetLibref = $event.toUpperCase()"
|
||||||
|
placeholder="e.g. MYLIB"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
(click)="downloadConfiguration()"
|
||||||
|
[disabled]="targetLibref !== dcLib && !isValidLibref(targetLibref)"
|
||||||
|
class="btn btn-info btn-sm"
|
||||||
|
>
|
||||||
DOWNLOAD
|
DOWNLOAD
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.libref-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.libref-label {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--clr-p4-color, #565656);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.libref-input {
|
||||||
|
width: 100px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { EnvironmentInfo } from './models/environment-info.model'
|
|||||||
import { AppSettingsService } from '../services/app-settings.service'
|
import { AppSettingsService } from '../services/app-settings.service'
|
||||||
import { AppSettings } from '../models/AppSettings'
|
import { AppSettings } from '../models/AppSettings'
|
||||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||||
|
import { globals } from '../_globals'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-system',
|
selector: 'app-system',
|
||||||
@@ -39,6 +40,8 @@ export class SystemComponent implements OnInit {
|
|||||||
responseModal: boolean = false
|
responseModal: boolean = false
|
||||||
|
|
||||||
Infinity = Infinity
|
Infinity = Infinity
|
||||||
|
dcLib: string = globals.dcLib
|
||||||
|
targetLibref: string = globals.dcLib
|
||||||
|
|
||||||
licenceState = this.licenceService.licenceState
|
licenceState = this.licenceService.licenceState
|
||||||
settings: AppSettings
|
settings: AppSettings
|
||||||
@@ -71,13 +74,21 @@ export class SystemComponent implements OnInit {
|
|||||||
this.appSettingsService.setAppSettings(this.settings)
|
this.appSettingsService.setAppSettings(this.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isValidLibref(value: string): boolean {
|
||||||
|
return /^[A-Za-z_]\w{0,7}$/.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
downloadConfiguration() {
|
downloadConfiguration() {
|
||||||
let sasjsConfig = this.sasService.getSasjsConfig()
|
let sasjsConfig = this.sasService.getSasjsConfig()
|
||||||
let storage = sasjsConfig.serverUrl
|
let storage = sasjsConfig.serverUrl
|
||||||
let metaData = sasjsConfig.appLoc
|
let metaData = sasjsConfig.appLoc
|
||||||
let path = this.sasService.getExecutionPath()
|
let path = this.sasService.getExecutionPath()
|
||||||
|
let lib = this.targetLibref.toUpperCase().trim()
|
||||||
let downUrl =
|
let downUrl =
|
||||||
storage + path + '/?_program=' + metaData + '/services/admin/exportconfig'
|
storage + path + '/?_program=' + metaData + '/services/admin/exportconfig'
|
||||||
|
if (lib && lib !== this.dcLib && this.isValidLibref(lib)) {
|
||||||
|
downUrl += '&dclib=' + encodeURIComponent(lib)
|
||||||
|
}
|
||||||
window.open(downUrl)
|
window.open(downUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,13 @@
|
|||||||
|
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="searchLibTreeInput.value.length < 1"
|
*ngIf="searchLibTreeInput.value.length < 1"
|
||||||
|
aria-hidden="true"
|
||||||
shape="search"
|
shape="search"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="searchLibTreeInput.value.length > 0"
|
*ngIf="searchLibTreeInput.value.length > 0"
|
||||||
(click)="librariesSearch = ''; libraryOnFilter()"
|
(click)="librariesSearch = ''; libraryOnFilter()"
|
||||||
|
aria-label="Clear search"
|
||||||
shape="times"
|
shape="times"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +46,7 @@
|
|||||||
"
|
"
|
||||||
class="m-0 cursor-pointer"
|
class="m-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<clr-icon shape="rack-server"></clr-icon>
|
<clr-icon aria-hidden="true" shape="rack-server"></clr-icon>
|
||||||
{{ library.LIBRARYNAME }}
|
{{ library.LIBRARYNAME }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
|
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="searchTreeInput.value.length < 1"
|
*ngIf="searchTreeInput.value.length < 1"
|
||||||
|
aria-hidden="true"
|
||||||
shape="search"
|
shape="search"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
@@ -73,6 +76,7 @@
|
|||||||
library['searchString'] = '';
|
library['searchString'] = '';
|
||||||
treeOnFilter(library, 'tables')
|
treeOnFilter(library, 'tables')
|
||||||
"
|
"
|
||||||
|
aria-label="Clear search"
|
||||||
shape="times"
|
shape="times"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,8 +100,16 @@
|
|||||||
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
[class.active]="libTabActive(library.LIBRARYREF, libTable)"
|
||||||
>
|
>
|
||||||
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
<ng-container [ngSwitch]="libTable.includes('-FC')">
|
||||||
<clr-icon *ngSwitchCase="true" shape="bolt"></clr-icon>
|
<clr-icon
|
||||||
<clr-icon *ngSwitchCase="false" shape="table"></clr-icon>
|
*ngSwitchCase="true"
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="bolt"
|
||||||
|
></clr-icon>
|
||||||
|
<clr-icon
|
||||||
|
*ngSwitchCase="false"
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="table"
|
||||||
|
></clr-icon>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
{{ libTable.replace('-FC', '') }}
|
{{ libTable.replace('-FC', '') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -345,6 +357,7 @@
|
|||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="!searchLoading"
|
*ngIf="!searchLoading"
|
||||||
(click)="searchTable(searchEl)"
|
(click)="searchTable(searchEl)"
|
||||||
|
aria-label="Search"
|
||||||
shape="search"
|
shape="search"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
<span *ngIf="searchLoading" class="spinner spinner-inline">
|
<span *ngIf="searchLoading" class="spinner spinner-inline">
|
||||||
@@ -383,6 +396,7 @@
|
|||||||
|
|
||||||
<clr-icon
|
<clr-icon
|
||||||
*ngIf="tableTitle?.includes('-FC')"
|
*ngIf="tableTitle?.includes('-FC')"
|
||||||
|
aria-hidden="true"
|
||||||
shape="bolt"
|
shape="bolt"
|
||||||
class="color-yellow mr-5"
|
class="color-yellow mr-5"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
@@ -411,6 +425,7 @@
|
|||||||
|
|
||||||
<clr-icon
|
<clr-icon
|
||||||
(click)="reloadTableData()"
|
(click)="reloadTableData()"
|
||||||
|
aria-label="Reload data"
|
||||||
class="refresh-table"
|
class="refresh-table"
|
||||||
shape="refresh"
|
shape="refresh"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
@@ -429,12 +444,12 @@
|
|||||||
class="btn btn-sm btn-outline filterSide"
|
class="btn btn-sm btn-outline filterSide"
|
||||||
clrDropdownTrigger
|
clrDropdownTrigger
|
||||||
>
|
>
|
||||||
<clr-icon shape="cog" size="15"></clr-icon>
|
<clr-icon aria-hidden="true" shape="cog" size="15"></clr-icon>
|
||||||
options
|
options
|
||||||
</button>
|
</button>
|
||||||
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
|
<clr-dropdown-menu clrPosition="bottom-right" *clrIfOpen>
|
||||||
<div (click)="newViewbox()" clrDropdownItem>
|
<div (click)="newViewbox()" clrDropdownItem>
|
||||||
<clr-icon shape="view-cards"></clr-icon>
|
<clr-icon aria-hidden="true" shape="view-cards"></clr-icon>
|
||||||
<span>Viewboxes</span>
|
<span>Viewboxes</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -442,23 +457,23 @@
|
|||||||
(click)="editTable()"
|
(click)="editTable()"
|
||||||
clrDropdownItem
|
clrDropdownItem
|
||||||
>
|
>
|
||||||
<clr-icon shape="pencil"></clr-icon>
|
<clr-icon aria-hidden="true" shape="pencil"></clr-icon>
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="tableuri" (click)="goToLineage()" clrDropdownItem>
|
<div *ngIf="tableuri" (click)="goToLineage()" clrDropdownItem>
|
||||||
<clr-icon shape="switch"></clr-icon>
|
<clr-icon aria-hidden="true" shape="switch"></clr-icon>
|
||||||
<span>Lineage</span>
|
<span>Lineage</span>
|
||||||
</div>
|
</div>
|
||||||
<div (click)="openQb()" clrDropdownItem>
|
<div (click)="openQb()" clrDropdownItem>
|
||||||
<clr-icon shape="filter"></clr-icon>
|
<clr-icon aria-hidden="true" shape="filter"></clr-icon>
|
||||||
<span>Filter</span>
|
<span>Filter</span>
|
||||||
</div>
|
</div>
|
||||||
<div (click)="openDownload = true" clrDropdownItem>
|
<div (click)="openDownload = true" clrDropdownItem>
|
||||||
<clr-icon shape="download"></clr-icon>
|
<clr-icon aria-hidden="true" shape="download"></clr-icon>
|
||||||
<span>Download</span>
|
<span>Download</span>
|
||||||
</div>
|
</div>
|
||||||
<div (click)="showWebQuery()" clrDropdownItem>
|
<div (click)="showWebQuery()" clrDropdownItem>
|
||||||
<clr-icon shape="download-cloud"></clr-icon>
|
<clr-icon aria-hidden="true" shape="download-cloud"></clr-icon>
|
||||||
<span>Web Query URL</span>
|
<span>Web Query URL</span>
|
||||||
</div>
|
</div>
|
||||||
</clr-dropdown-menu>
|
</clr-dropdown-menu>
|
||||||
@@ -505,6 +520,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<clr-icon
|
<clr-icon
|
||||||
(click)="reloadLibInfo()"
|
(click)="reloadLibInfo()"
|
||||||
|
aria-label="Reload library info"
|
||||||
class="refresh-table"
|
class="refresh-table"
|
||||||
shape="refresh"
|
shape="refresh"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
@@ -536,6 +552,7 @@
|
|||||||
No library info found. Click
|
No library info found. Click
|
||||||
<clr-icon
|
<clr-icon
|
||||||
(click)="reloadLibInfo()"
|
(click)="reloadLibInfo()"
|
||||||
|
aria-label="Reload library info"
|
||||||
class="refresh-table m-0"
|
class="refresh-table m-0"
|
||||||
shape="refresh"
|
shape="refresh"
|
||||||
></clr-icon>
|
></clr-icon>
|
||||||
@@ -610,7 +627,12 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<div *ngIf="noData || noDataReqErr" class="card-block noData">
|
<div *ngIf="noData || noDataReqErr" class="card-block noData">
|
||||||
<clr-icon shape="warning-standard" size="60" class="is-info"></clr-icon>
|
<clr-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
shape="warning-standard"
|
||||||
|
size="60"
|
||||||
|
class="is-info"
|
||||||
|
></clr-icon>
|
||||||
<h3 *ngIf="noData" class="text-center color-gray">
|
<h3 *ngIf="noData" class="text-center color-gray">
|
||||||
No data found with given conditions
|
No data found with given conditions
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { QueryComponent } from '../query/query.component'
|
|||||||
|
|
||||||
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
import { FilterGroup, FilterQuery } from '../models/FilterQuery'
|
||||||
import { HotTableInterface } from '../models/HotTable.interface'
|
import { HotTableInterface } from '../models/HotTable.interface'
|
||||||
|
import { buildExportMenuItem } from '../shared/hot-export/hot-export.util'
|
||||||
import { LoggerService } from '../services/logger.service'
|
import { LoggerService } from '../services/logger.service'
|
||||||
import Handsontable from 'handsontable'
|
import Handsontable from 'handsontable'
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
Version
|
Version
|
||||||
} from '../models/sas/editors-getdata.model'
|
} from '../models/sas/editors-getdata.model'
|
||||||
import { mergeColsRules } from '../shared/dc-validator/utils/mergeColsRules'
|
import { mergeColsRules } from '../shared/dc-validator/utils/mergeColsRules'
|
||||||
|
import { makeNumberFormatRenderer } from '../editor/utils/renderers.utils'
|
||||||
import { PublicViewtablesServiceResponse } from '../models/sas/public-viewtables.model'
|
import { PublicViewtablesServiceResponse } from '../models/sas/public-viewtables.model'
|
||||||
import { PublicViewlibsServiceResponse } from '../models/sas/public-viewlibs.model'
|
import { PublicViewlibsServiceResponse } from '../models/sas/public-viewlibs.model'
|
||||||
import { PublicRefreshlibinfoServiceResponse } from '../models/sas/public-refreshlibinfo.model'
|
import { PublicRefreshlibinfoServiceResponse } from '../models/sas/public-refreshlibinfo.model'
|
||||||
@@ -40,7 +42,6 @@ import { DataFormat } from '../models/sas/common/DateFormat'
|
|||||||
import { Libinfo } from '../models/sas/common/Libinfo'
|
import { Libinfo } from '../models/sas/common/Libinfo'
|
||||||
import { LicenceService } from '../services/licence.service'
|
import { LicenceService } from '../services/licence.service'
|
||||||
import { Location } from '@angular/common'
|
import { Location } from '@angular/common'
|
||||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-viewer',
|
selector: 'app-viewer',
|
||||||
@@ -122,6 +123,7 @@ export class ViewerComponent
|
|||||||
stretchH: 'all',
|
stretchH: 'all',
|
||||||
modifyColWidth: this.maxWidthCheker,
|
modifyColWidth: this.maxWidthCheker,
|
||||||
cells: this.hotTable.cells,
|
cells: this.hotTable.cells,
|
||||||
|
hiddenColumns: { columns: this.hiddenViewColumns, indicators: false },
|
||||||
maxRows: this.hotTable.maxRows,
|
maxRows: this.hotTable.maxRows,
|
||||||
manualColumnResize: true,
|
manualColumnResize: true,
|
||||||
afterGetColHeader: this.hotTable.afterGetColHeader,
|
afterGetColHeader: this.hotTable.afterGetColHeader,
|
||||||
@@ -129,12 +131,15 @@ export class ViewerComponent
|
|||||||
rowHeaderWidth: this.hotTable.rowHeaderWidth,
|
rowHeaderWidth: this.hotTable.rowHeaderWidth,
|
||||||
rowHeights: this.hotTable.rowHeights,
|
rowHeights: this.hotTable.rowHeights,
|
||||||
licenseKey: this.hotTable.licenseKey,
|
licenseKey: this.hotTable.licenseKey,
|
||||||
className: 'htDark'
|
className: 'htDark',
|
||||||
|
theme: 'ht-theme-classic'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public numberOfRows: number | null = null
|
public numberOfRows: number | null = null
|
||||||
public headerPks: string[] = []
|
public headerPks: string[] = []
|
||||||
public $dataFormats: $DataFormats | null = null
|
public $dataFormats: $DataFormats | null = null
|
||||||
|
// Physical column indexes to hide in the viewer (from HIDDEN DQ rules)
|
||||||
|
public hiddenViewColumns: number[] = []
|
||||||
public datasetInfo: boolean = false
|
public datasetInfo: boolean = false
|
||||||
public dsmeta: DSMeta[] = []
|
public dsmeta: DSMeta[] = []
|
||||||
public versions: Version[] = []
|
public versions: Version[] = []
|
||||||
@@ -160,11 +165,7 @@ export class ViewerComponent
|
|||||||
afterGetColHeader: (col: number, th: any, headerLevel: number) => {
|
afterGetColHeader: (col: number, th: any, headerLevel: number) => {
|
||||||
// CRITICAL: Prevent "colToProp method cannot be called because this Handsontable instance has been destroyed" error
|
// CRITICAL: Prevent "colToProp method cannot be called because this Handsontable instance has been destroyed" error
|
||||||
// This callback can be triggered even after the instance is destroyed during rapid table switching
|
// This callback can be triggered even after the instance is destroyed during rapid table switching
|
||||||
if (
|
if (!this.hotInstance || this.hotInstance.isDestroyed) {
|
||||||
!this.hotInstance ||
|
|
||||||
this.hotInstance.isDestroyed ||
|
|
||||||
this.isTableSwitching
|
|
||||||
) {
|
|
||||||
// Graceful fallback: apply only dark mode styling when instance is unavailable
|
// Graceful fallback: apply only dark mode styling when instance is unavailable
|
||||||
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
||||||
return
|
return
|
||||||
@@ -185,7 +186,13 @@ export class ViewerComponent
|
|||||||
},
|
},
|
||||||
rowHeaderWidth: 15,
|
rowHeaderWidth: 15,
|
||||||
rowHeights: 20,
|
rowHeights: 20,
|
||||||
contextMenu: ['copy_with_column_headers', 'copy_column_headers_only'],
|
contextMenu: {
|
||||||
|
items: {
|
||||||
|
copy_with_column_headers: {},
|
||||||
|
copy_column_headers_only: {},
|
||||||
|
export_file: buildExportMenuItem(() => this.tableTitle || 'export')
|
||||||
|
}
|
||||||
|
},
|
||||||
copyPaste: {
|
copyPaste: {
|
||||||
copyColumnHeaders: true,
|
copyColumnHeaders: true,
|
||||||
copyColumnHeadersOnly: true
|
copyColumnHeadersOnly: true
|
||||||
@@ -761,10 +768,6 @@ export class ViewerComponent
|
|||||||
// This prevents callbacks from accessing destroyed instances during table switching
|
// This prevents callbacks from accessing destroyed instances during table switching
|
||||||
this.isTableSwitching = true
|
this.isTableSwitching = true
|
||||||
|
|
||||||
// CLEANUP: Ensure any existing Handsontable instance is properly destroyed
|
|
||||||
// This prevents "instance destroyed" errors
|
|
||||||
this.cleanupHotInstance()
|
|
||||||
|
|
||||||
this.loadingTableView = true
|
this.loadingTableView = true
|
||||||
|
|
||||||
let libDataset: any
|
let libDataset: any
|
||||||
@@ -777,18 +780,14 @@ export class ViewerComponent
|
|||||||
let ds = []
|
let ds = []
|
||||||
ds = this.libDataset.split('.')
|
ds = this.libDataset.split('.')
|
||||||
|
|
||||||
if (globals.viewer.startupSet) {
|
await this.sasStoreService
|
||||||
this.libraries = globals.viewer.libraries
|
.viewLibs()
|
||||||
} else {
|
.then((res: any) => {
|
||||||
await this.sasStoreService
|
this.libraries = res.saslibs
|
||||||
.viewLibs()
|
})
|
||||||
.then((res: any) => {
|
.catch((err: any) => {
|
||||||
this.libraries = res.saslibs
|
this.loggerService.error(err)
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
|
||||||
this.loggerService.error(err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lib = ds[0]
|
this.lib = ds[0]
|
||||||
|
|
||||||
@@ -823,18 +822,14 @@ export class ViewerComponent
|
|||||||
libDataset = this.libDataset
|
libDataset = this.libDataset
|
||||||
this.libTab = libDataset
|
this.libTab = libDataset
|
||||||
} else {
|
} else {
|
||||||
if (globals.viewer.startupSet) {
|
await this.sasStoreService
|
||||||
this.libraries = globals.viewer.libraries
|
.viewLibs()
|
||||||
} else {
|
.then((res: any) => {
|
||||||
await this.sasStoreService
|
this.libraries = res.saslibs
|
||||||
.viewLibs()
|
})
|
||||||
.then((res: any) => {
|
.catch((err: any) => {
|
||||||
this.libraries = res.saslibs
|
this.loggerService.error(err)
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
|
||||||
this.loggerService.error(err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof this.table !== 'undefined') {
|
if (typeof this.table !== 'undefined') {
|
||||||
if (globals.viewer.startupSet) {
|
if (globals.viewer.startupSet) {
|
||||||
@@ -919,16 +914,43 @@ export class ViewerComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NUMBER_FORMAT (display) and HIDDEN (visibility) are the DQ rules
|
||||||
|
// that make sense in the read-only viewer; READONLY/ROUND are
|
||||||
|
// editor-only. Build lookups for both from the response.
|
||||||
|
const numberFormats: { [col: string]: string } = {}
|
||||||
|
const hiddenCols = new Set<string>()
|
||||||
|
if (Array.isArray(res.dqrules)) {
|
||||||
|
for (const rule of res.dqrules) {
|
||||||
|
if (rule.RULE_TYPE === 'NUMBER_FORMAT') {
|
||||||
|
numberFormats[rule.BASE_COL] = rule.RULE_VALUE
|
||||||
|
}
|
||||||
|
if (rule.RULE_TYPE === 'HIDDEN') {
|
||||||
|
hiddenCols.add(rule.BASE_COL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiddenColumnIndexes: number[] = []
|
||||||
for (let index = 0; index < colArr.length; index++) {
|
for (let index = 0; index < colArr.length; index++) {
|
||||||
columns.push({ data: colArr[index] })
|
const col = colArr[index]
|
||||||
|
const colDef: any = { data: col }
|
||||||
|
if (numberFormats[col]) {
|
||||||
|
colDef.renderer = makeNumberFormatRenderer(numberFormats[col])
|
||||||
|
}
|
||||||
|
if (hiddenCols.has(col)) {
|
||||||
|
hiddenColumnIndexes.push(index)
|
||||||
|
}
|
||||||
|
columns.push(colDef)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hotTable.colHeaders = colArr
|
this.hotTable.colHeaders = colArr
|
||||||
this.hotTable.columns = columns
|
this.hotTable.columns = columns
|
||||||
|
this.hiddenViewColumns = hiddenColumnIndexes
|
||||||
} else {
|
} else {
|
||||||
// Set empty arrays if no data
|
// Set empty arrays if no data
|
||||||
this.hotTable.colHeaders = []
|
this.hotTable.colHeaders = []
|
||||||
this.hotTable.columns = []
|
this.hotTable.columns = []
|
||||||
|
this.hiddenViewColumns = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cells function
|
// Set cells function
|
||||||
@@ -1177,8 +1199,6 @@ export class ViewerComponent
|
|||||||
* Purpose: Prevents "instance destroyed" errors and memory leaks during table switching
|
* Purpose: Prevents "instance destroyed" errors and memory leaks during table switching
|
||||||
*
|
*
|
||||||
* Called from:
|
* Called from:
|
||||||
* - viewData() - before loading new table data
|
|
||||||
* - setupHot() - before creating new instance
|
|
||||||
* - ngOnDestroy() - component cleanup
|
* - ngOnDestroy() - component cleanup
|
||||||
*
|
*
|
||||||
* Safety features:
|
* Safety features:
|
||||||
@@ -1195,107 +1215,113 @@ export class ViewerComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.hotInstance = null
|
this.hotInstance = null
|
||||||
|
this.hooksAttached = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PERFORMANCE: Configures Handsontable with enhanced error handling (workaround needed for HOT version 16 and above)
|
* PERFORMANCE: Configures Handsontable with enhanced error handling (workaround needed for HOT version 16 and above)
|
||||||
*
|
*
|
||||||
* 1. Duplicate call prevention (500ms window)
|
* 1. Duplicate call prevention (500ms window)
|
||||||
* 2. Reduced timeout delays (200ms + 50ms vs original 1000ms + 200ms)
|
* 2. Multiple validation checks to prevent race conditions
|
||||||
* 3. Multiple validation checks to prevent race conditions
|
* 3. Forced render for immediate primary key styling
|
||||||
* 4. Forced render for immediate primary key styling
|
|
||||||
*
|
*
|
||||||
* Timeline: 50ms (viewData) + 200ms (main) + 50ms (component ready) = ~300ms total
|
* Instance lifecycle is managed by Angular's hot-table component via [data] and [settings] bindings.
|
||||||
* Previous: 100ms + 600ms + 100ms = 800ms (plus render delays = ~2 seconds)
|
* This method only applies additional config that can't go through bindings (hooks, PK styling).
|
||||||
*/
|
*/
|
||||||
private setupHot() {
|
private setupHot() {
|
||||||
// DUPLICATE PREVENTION: Avoid multiple setup calls during rapid table switching
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - this.lastSetupTime < 500) {
|
if (now - this.lastSetupTime < 500) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.lastSetupTime = now
|
this.lastSetupTime = now
|
||||||
|
|
||||||
|
// VALIDATION: Don't setup if we're currently switching tables or still loading
|
||||||
|
if (this.loadingTableView || !this.libDataset) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.hotInstance || this.hotInstance.isDestroyed) {
|
||||||
|
this.hotInstance = this.hotTableComponent?.hotInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hotInstance && !this.hotInstance.isDestroyed) {
|
||||||
|
this.configureHotInstance()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instance not ready yet — Angular may still be creating the component
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// VALIDATION: Don't setup if we're currently switching tables or still loading
|
if (this.isTableSwitching || this.loadingTableView || !this.libDataset) {
|
||||||
if (this.loadingTableView || !this.libDataset) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLEANUP: Ensure clean slate before new setup
|
this.hotInstance = this.hotTableComponent?.hotInstance
|
||||||
this.cleanupHotInstance()
|
this.configureHotInstance()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
// TIMING: Wait for Angular component to be ready (optimized from 100ms to 50ms)
|
private hooksAttached = false
|
||||||
setTimeout(() => {
|
|
||||||
// DOUBLE-CHECK: Ensure we're still in valid state after delays
|
/**
|
||||||
if (
|
* Applies settings that can't go through Angular [settings] binding:
|
||||||
this.isTableSwitching ||
|
* - Primary key column header styling
|
||||||
this.loadingTableView ||
|
* - Column width cap
|
||||||
!this.libDataset
|
* - ARIA accessibility hooks (attached once per instance)
|
||||||
) {
|
*/
|
||||||
|
private configureHotInstance() {
|
||||||
|
if (!this.hotInstance || this.hotInstance.isDestroyed) return
|
||||||
|
|
||||||
|
this.hotInstance.updateSettings({
|
||||||
|
height: this.hotTable.height,
|
||||||
|
modifyColWidth: (width: any, col: any) => {
|
||||||
|
if (width > 500) return 500
|
||||||
|
else return width
|
||||||
|
},
|
||||||
|
afterGetColHeader: (col: number, th: any) => {
|
||||||
|
// CRITICAL: Same safety checks as initial callback to prevent destroyed instance errors
|
||||||
|
if (!this.hotInstance || this.hotInstance.isDestroyed) {
|
||||||
|
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.hotInstance = this.hotTableComponent?.hotInstance
|
try {
|
||||||
|
const column = this.hotInstance.colToProp(col) as string
|
||||||
|
|
||||||
if (this.hotInstance && !this.hotInstance.isDestroyed) {
|
// PRIMARY KEY STYLING: Apply special styling to PK columns (populated from API response)
|
||||||
this.hotInstance.updateSettings({
|
const isPKCol = column && this.headerPks.indexOf(column) > -1
|
||||||
height: this.hotTable.height,
|
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
|
||||||
modifyColWidth: (width: any, col: any) => {
|
|
||||||
if (width > 500) return 500
|
|
||||||
else return width
|
|
||||||
},
|
|
||||||
afterGetColHeader: (col: number, th: any) => {
|
|
||||||
// CRITICAL: Same safety checks as initial callback to prevent destroyed instance errors
|
|
||||||
if (
|
|
||||||
!this.hotInstance ||
|
|
||||||
this.hotInstance.isDestroyed ||
|
|
||||||
this.isTableSwitching
|
|
||||||
) {
|
|
||||||
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// DARK MODE: Apply to all headers
|
||||||
const column = this.hotInstance.colToProp(col) as string
|
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
||||||
|
} catch (error) {
|
||||||
// PRIMARY KEY STYLING: Apply special styling to PK columns (populated from API response)
|
// SAFETY NET: Ensure basic styling is always applied
|
||||||
const isPKCol = column && this.headerPks.indexOf(column) > -1
|
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
||||||
if (isPKCol) th.classList.add('primaryKeyHeaderStyle')
|
|
||||||
|
|
||||||
// DARK MODE: Apply to all headers
|
|
||||||
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
|
||||||
} catch (error) {
|
|
||||||
// SAFETY NET: Ensure basic styling is always applied
|
|
||||||
th.classList.add(globals.handsontable.darkTableHeaderClass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Add hooks for accessibility fixes
|
|
||||||
this.hotInstance.addHook('afterRender', () => {
|
|
||||||
// Fix ARIA accessibility issues after each render
|
|
||||||
this.fixAriaAccessibility()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.hotInstance.addHook('afterChange', () => {
|
|
||||||
// Fix ARIA accessibility issues after any data change
|
|
||||||
setTimeout(() => {
|
|
||||||
this.fixAriaAccessibility()
|
|
||||||
}, 50)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Force immediate render to apply primary key styling
|
|
||||||
// Without this, styling would wait for ~2 seconds to be applied
|
|
||||||
// With this, styling appears in ~300ms total (workaround needed for HOT version 16 and above)
|
|
||||||
setTimeout(() => {
|
|
||||||
if (this.hotInstance && !this.hotInstance.isDestroyed) {
|
|
||||||
this.hotInstance.render()
|
|
||||||
}
|
|
||||||
}, 10)
|
|
||||||
}
|
}
|
||||||
}, 50) // Optimized Angular component readiness delay
|
}
|
||||||
}, 200) // Optimized main setup delay (was 600ms)
|
})
|
||||||
|
|
||||||
|
// Add hooks for accessibility fixes
|
||||||
|
// Hooks are attached once per instance to avoid accumulating duplicate listeners
|
||||||
|
if (!this.hooksAttached) {
|
||||||
|
this.hotInstance.addHook('afterRender', () => {
|
||||||
|
// Fix ARIA accessibility issues after each render
|
||||||
|
this.fixAriaAccessibility()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.hotInstance.addHook('afterChange', () => {
|
||||||
|
// Fix ARIA accessibility issues after any data change
|
||||||
|
setTimeout(() => {
|
||||||
|
this.fixAriaAccessibility()
|
||||||
|
}, 50)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.hooksAttached = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force immediate render to apply primary key styling
|
||||||
|
// Without this, styling would wait for ~2 seconds to be applied
|
||||||
|
// (workaround needed for HOT version 16 and above)
|
||||||
|
this.hotInstance.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadWithParameters() {
|
async loadWithParameters() {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
<hot-table
|
<hot-table
|
||||||
id="hot-table"
|
id="hot-table"
|
||||||
[data]="selectedTab === TabsEnum.Rules ? xlmapRules : xlData"
|
[data]="selectedTab === TabsEnum.Rules ? xlmapRules : xlData"
|
||||||
[settings]="hotTableSettings"
|
[settings]="hotTableSettings()"
|
||||||
>
|
>
|
||||||
</hot-table>
|
</hot-table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
HostBinding,
|
HostBinding,
|
||||||
OnInit,
|
OnInit,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
ViewEncapsulation
|
ViewEncapsulation
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -138,7 +139,13 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
|||||||
public hotTableMaxRows =
|
public hotTableMaxRows =
|
||||||
this.licenceState.value.viewer_rows_allowed || Infinity
|
this.licenceState.value.viewer_rows_allowed || Infinity
|
||||||
|
|
||||||
get hotTableSettings(): Handsontable.GridSettings {
|
// Stable settings reference so the HOT v17 wrapper doesn't re-run
|
||||||
|
// updateSettings on every change-detection tick (the previous getter returned a
|
||||||
|
// new object each call, causing an infinite render loop / freeze). Rebuilt only
|
||||||
|
// when the selected tab, or the license, changes (see setSelectedTab + ngOnInit).
|
||||||
|
hotTableSettings = signal<Handsontable.GridSettings>(this.buildHotSettings())
|
||||||
|
|
||||||
|
private buildHotSettings(): Handsontable.GridSettings {
|
||||||
return {
|
return {
|
||||||
multiColumnSorting: true,
|
multiColumnSorting: true,
|
||||||
viewportRowRenderingOffset: 50,
|
viewportRowRenderingOffset: 50,
|
||||||
@@ -162,10 +169,17 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
|||||||
rowHeaderWidth: 15,
|
rowHeaderWidth: 15,
|
||||||
rowHeights: 20,
|
rowHeights: 20,
|
||||||
licenseKey: this.hotTableLicenseKey,
|
licenseKey: this.hotTableLicenseKey,
|
||||||
className: 'htDark'
|
className: 'htDark',
|
||||||
|
theme: 'ht-theme-classic'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single place that changes the active tab so the settings signal stays in sync.
|
||||||
|
private setSelectedTab(tab: Tabs) {
|
||||||
|
this.selectedTab = tab
|
||||||
|
this.hotTableSettings.set(this.buildHotSettings())
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private eventService: EventService,
|
private eventService: EventService,
|
||||||
private licenceService: LicenceService,
|
private licenceService: LicenceService,
|
||||||
@@ -190,7 +204,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
|||||||
if (this.fileUploadInputCompList.first) {
|
if (this.fileUploadInputCompList.first) {
|
||||||
this.fileUploadInputCompList.first.nativeElement.value = ''
|
this.fileUploadInputCompList.first.nativeElement.value = ''
|
||||||
}
|
}
|
||||||
this.selectedTab = Tabs.Rules
|
this.setSelectedTab(Tabs.Rules)
|
||||||
this.viewXLMapRules()
|
this.viewXLMapRules()
|
||||||
this.router.navigateByUrl('/home/excel-maps/' + xlmap.id)
|
this.router.navigateByUrl('/home/excel-maps/' + xlmap.id)
|
||||||
}
|
}
|
||||||
@@ -316,7 +330,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
|||||||
this.isLoadingDesc = ''
|
this.isLoadingDesc = ''
|
||||||
this.status = Status.ReadyToUpload
|
this.status = Status.ReadyToUpload
|
||||||
this.xlData = []
|
this.xlData = []
|
||||||
this.selectedTab = Tabs.Rules
|
this.setSelectedTab(Tabs.Rules)
|
||||||
this.filename = ''
|
this.filename = ''
|
||||||
this.uploader.queue = []
|
this.uploader.queue = []
|
||||||
if (this.fileUploadInputCompList.first) {
|
if (this.fileUploadInputCompList.first) {
|
||||||
@@ -466,7 +480,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
|||||||
this.isLoadingDesc = ''
|
this.isLoadingDesc = ''
|
||||||
|
|
||||||
this.xlData = extractedData
|
this.xlData = extractedData
|
||||||
this.selectedTab = Tabs.Data
|
this.setSelectedTab(Tabs.Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async viewXLMapRules() {
|
async viewXLMapRules() {
|
||||||
@@ -509,6 +523,7 @@ export class XLMapComponent implements AfterContentInit, AfterViewInit, OnInit {
|
|||||||
this.licenceService.hot_license_key.subscribe(
|
this.licenceService.hot_license_key.subscribe(
|
||||||
(hot_license_key: string | undefined) => {
|
(hot_license_key: string | undefined) => {
|
||||||
this.hotTableLicenseKey = hot_license_key
|
this.hotTableLicenseKey = hot_license_key
|
||||||
|
this.hotTableSettings.set(this.buildHotSettings())
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user