Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c1c1d0055a | |||
| f37ec82d39 | |||
| 6e9e30e0f0 | |||
| 990ddb5cd3 | |||
| c6ebbb48bb | |||
| 95cc0b1c91 | |||
| 81b282f1f1 | |||
| 8c5b357dd2 | |||
| a13b2cbfd2 | |||
| 19617c2285 | |||
| f9794a973f | |||
| 65efe62d19 | |||
| 683ddcaf53 | |||
| 113e0bbc3c | |||
| 2af97e40bf | |||
| 83cbe3aece | |||
| ceac1ba614 | |||
| 765fdbdf9d | |||
| 43ae73c5f3 | |||
| e57a0de8a9 | |||
| 3de491105b | |||
| 2fe690e962 | |||
| b826d37086 | |||
| bfbfd55fe7 | |||
| 15f38efd52 | |||
| 5d25681485 | |||
| d26df376f8 | |||
| cff3fb3bad | |||
| fb3c49aa8b | |||
| af1657e226 | |||
| d7c7302c12 | |||
| 26ce95f7c1 | |||
| 4924df2ef3 | |||
| 2e141a5d52 | |||
| cb1978bcaf | |||
| 387f5122f1 |
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+49
-37
@@ -2,39 +2,53 @@ name: Build
|
||||
run-name: Running Lint Check and Licence checker on Pull Request
|
||||
on: [pull_request]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
Build-and-ng-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.15.1
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Google Chrome
|
||||
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 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
|
||||
run: echo "$NPMRC" > client/.npmrc
|
||||
run: echo "$NPMRC" >> client/.npmrc
|
||||
shell: bash
|
||||
env:
|
||||
NPMRC: ${{ secrets.NPMRC}}
|
||||
|
||||
- name: Lint check
|
||||
run: npm run lint:check
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd client
|
||||
# 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
|
||||
|
||||
- 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
|
||||
run: |
|
||||
cd client
|
||||
@@ -52,26 +66,27 @@ jobs:
|
||||
|
||||
Build-and-test-development:
|
||||
runs-on: ubuntu-latest
|
||||
needs: Build-production-and-ng-test
|
||||
needs: Build-and-ng-test
|
||||
env:
|
||||
CHROME_BIN: /usr/bin/google-chrome
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.15.1
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Write .npmrc file
|
||||
run: |
|
||||
touch client/.npmrc
|
||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||
|
||||
- run: apt-get update
|
||||
- run: wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
- run: apt install -y ./google-chrome*.deb;
|
||||
- run: export CHROME_BIN=/usr/bin/google-chrome
|
||||
- run: apt-get update -y
|
||||
- run: apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
|
||||
- run: apt -y install jq
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt install -y ./google-chrome*.deb
|
||||
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip
|
||||
|
||||
- name: Write cypress credentials
|
||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||
@@ -86,17 +101,18 @@ jobs:
|
||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||
npm ci
|
||||
|
||||
# Install pm2 and prepare SASJS server
|
||||
- run: npm i -g pm2
|
||||
- run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
- run: unzip linux.zip
|
||||
- run: touch .env
|
||||
- run: echo RUN_TIMES=js >> .env
|
||||
- run: echo NODE_PATH=node >> .env
|
||||
- run: echo CORS=enable >> .env
|
||||
- run: echo WHITELIST=http://localhost:4200 >> .env
|
||||
- run: cat .env
|
||||
- run: pm2 start api-linux --wait-ready
|
||||
- name: Setup and start SASjs server
|
||||
run: |
|
||||
npm i -g pm2
|
||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
unzip linux.zip
|
||||
touch .env
|
||||
echo RUN_TIMES=js >> .env
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .env
|
||||
cat .env
|
||||
pm2 start api-linux --wait-ready
|
||||
|
||||
- name: Deploy mocked services
|
||||
run: |
|
||||
@@ -106,11 +122,6 @@ jobs:
|
||||
sasjs cbd -t server-ci
|
||||
# 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
|
||||
run: |
|
||||
cd ./client
|
||||
@@ -126,11 +137,12 @@ jobs:
|
||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||
cat ./cypress.config.ts
|
||||
# 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
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p ./client/cypress/videos
|
||||
zip -r cypress-videos ./client/cypress/videos
|
||||
|
||||
- name: Add cypress videos artifacts
|
||||
|
||||
@@ -2,38 +2,31 @@ name: Lighthouse Checks
|
||||
run-name: Running Lighthouse Performance and Accessibility Checks on Pull Request
|
||||
on: [pull_request]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.15.0'
|
||||
|
||||
jobs:
|
||||
lighthouse:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.15.1]
|
||||
|
||||
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
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install Google Chrome
|
||||
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 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
|
||||
run: npm i -g pm2
|
||||
- name: Install global packages
|
||||
run: npm i -g pm2 @sasjs/cli wait-on
|
||||
|
||||
- name: Install @sasjs/cli
|
||||
run: npm i -g @sasjs/cli
|
||||
|
||||
- name: Install wait-on globally
|
||||
run: npm install -g wait-on
|
||||
|
||||
- name: Create .env file for sasjs/server
|
||||
- name: Setup and start SASjs server
|
||||
run: |
|
||||
touch .env
|
||||
echo RUN_TIMES=js >> .env
|
||||
@@ -41,15 +34,9 @@ jobs:
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .env
|
||||
cat .env
|
||||
|
||||
- name: Download sasjs/server package from github using curl
|
||||
run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
|
||||
- name: Unzip downloaded package
|
||||
run: unzip linux.zip
|
||||
|
||||
- name: Run sasjs server
|
||||
run: pm2 start api-linux --wait-ready
|
||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
unzip linux.zip
|
||||
pm2 start api-linux --wait-ready
|
||||
|
||||
- name: Write .npmrc file
|
||||
run: echo "$NPMRC" > client/.npmrc
|
||||
@@ -61,7 +48,10 @@ jobs:
|
||||
run: |
|
||||
cd client
|
||||
# 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 install -g replace-in-files-cli
|
||||
|
||||
@@ -98,4 +88,4 @@ jobs:
|
||||
with:
|
||||
name: Lighthouse results
|
||||
path: client/lighthouse-reports
|
||||
include-hidden-files: true
|
||||
include-hidden-files: true
|
||||
|
||||
@@ -5,27 +5,31 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
NODE_VERSION: '24.5.0'
|
||||
|
||||
jobs:
|
||||
Build-production-and-ng-test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CHROME_BIN: /usr/bin/google-chrome
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.14.0
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Write .npmrc file
|
||||
run: |
|
||||
touch client/.npmrc
|
||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||
touch client/.npmrc
|
||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||
|
||||
- name: Install Chrome for Angular tests
|
||||
run: |
|
||||
apt-get update
|
||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt install -y ./google-chrome*.deb;
|
||||
export CHROME_BIN=/usr/bin/google-chrome
|
||||
apt install -y ./google-chrome*.deb
|
||||
|
||||
- name: Write cypress credentials
|
||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||
@@ -41,11 +45,11 @@ jobs:
|
||||
npm ci
|
||||
|
||||
- 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: |
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
cd ./sas
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
npm audit --omit=dev
|
||||
cd ../client
|
||||
npm audit --audit-level=critical --omit=dev
|
||||
|
||||
@@ -63,25 +67,26 @@ jobs:
|
||||
Build-and-test-development:
|
||||
runs-on: ubuntu-latest
|
||||
needs: Build-production-and-ng-test
|
||||
env:
|
||||
CHROME_BIN: /usr/bin/google-chrome
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.14.0
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Write .npmrc file
|
||||
run: |
|
||||
touch client/.npmrc
|
||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||
touch client/.npmrc
|
||||
echo '${{ secrets.NPMRC}}' > client/.npmrc
|
||||
|
||||
- run: apt-get update
|
||||
- run: wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
- run: apt install -y ./google-chrome*.deb;
|
||||
- run: export CHROME_BIN=/usr/bin/google-chrome
|
||||
- run: apt-get update -y
|
||||
- run: apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb
|
||||
- run: apt -y install jq
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
apt install -y ./google-chrome*.deb
|
||||
apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb jq zip
|
||||
|
||||
- name: Write cypress credentials
|
||||
run: echo "$CYPRESS_CREDS" > ./client/cypress.env.json
|
||||
@@ -96,17 +101,18 @@ jobs:
|
||||
echo ${{ secrets.SHEET_PWD }} | gpg --batch --yes --passphrase-fd 0 ./libraries/sheet-crypto.tgz.gpg
|
||||
npm ci
|
||||
|
||||
# Install pm2 and prepare SASJS server
|
||||
- run: npm i -g pm2
|
||||
- run: curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
- run: unzip linux.zip
|
||||
- run: touch .env
|
||||
- run: echo RUN_TIMES=js >> .env
|
||||
- run: echo NODE_PATH=node >> .env
|
||||
- run: echo CORS=enable >> .env
|
||||
- run: echo WHITELIST=http://localhost:4200 >> .env
|
||||
- run: cat .env
|
||||
- run: pm2 start api-linux --wait-ready
|
||||
- name: Setup and start SASjs server
|
||||
run: |
|
||||
npm i -g pm2
|
||||
curl -L https://github.com/sasjs/server/releases/latest/download/linux.zip > linux.zip
|
||||
unzip linux.zip
|
||||
touch .env
|
||||
echo RUN_TIMES=js >> .env
|
||||
echo NODE_PATH=node >> .env
|
||||
echo CORS=enable >> .env
|
||||
echo WHITELIST=http://localhost:4200 >> .env
|
||||
cat .env
|
||||
pm2 start api-linux --wait-ready
|
||||
|
||||
- name: Deploy mocked services
|
||||
run: |
|
||||
@@ -116,11 +122,6 @@ jobs:
|
||||
sasjs cbd -t server-ci
|
||||
# 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
|
||||
run: |
|
||||
cd ./client
|
||||
@@ -136,11 +137,12 @@ jobs:
|
||||
replace-in-files --regex='"hosturl".*' --replacement='hosturl:"http://localhost:4200",' ./cypress.config.ts
|
||||
cat ./cypress.config.ts
|
||||
# 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
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p ./client/cypress/videos
|
||||
zip -r cypress-videos ./client/cypress/videos
|
||||
|
||||
- name: Add cypress videos artifacts
|
||||
@@ -155,10 +157,10 @@ jobs:
|
||||
needs: [Build-production-and-ng-test, Build-and-test-development]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.14.0
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Write .npmrc file
|
||||
run: |
|
||||
@@ -168,17 +170,11 @@ jobs:
|
||||
env:
|
||||
NPMRC: ${{ secrets.NPMRC}}
|
||||
|
||||
- name: Install packages
|
||||
- name: Install system packages
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install zip -y
|
||||
# sasjs cli is used to compile & build the SAS services
|
||||
apt-get install -y zip jq doxygen
|
||||
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
|
||||
description: We want to prevent creating empty release if frontend fails
|
||||
@@ -228,6 +224,8 @@ jobs:
|
||||
cp sasjs/utils/favicon.ico ../client/dist/favicon.ico
|
||||
sasjs c -t server
|
||||
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
|
||||
cp sasjsbuild/server.json.zip ./sasjs_server.json.zip
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ client/documentation
|
||||
client/**/sheet-crypto.tgz
|
||||
client/.nx
|
||||
client/libraries/sheet-crypto.tgz
|
||||
client/lighthouse-reports
|
||||
cypress.env.json
|
||||
sasjsbuild
|
||||
sasjsresults
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
legacy-peer-deps=true
|
||||
ignore-scripts=true
|
||||
save-exact=true
|
||||
fund=false
|
||||
+192
@@ -1,3 +1,195 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update angular and moment ([8c5b357](https://git.datacontroller.io/dc/dc/commit/8c5b357dd286db331a6dcdeb3fd499fe3b634288))
|
||||
|
||||
## [7.2.5](https://git.datacontroller.io/dc/dc/compare/v7.2.4...v7.2.5) (2025-12-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* (build) rebuilt package-lock files ([bfbfd55](https://git.datacontroller.io/dc/dc/commit/bfbfd55fe7e2dff3ce707763a2c7939ff365318b))
|
||||
* (deps) bump @sasjs/cli and @sasjs/core ([d7c7302](https://git.datacontroller.io/dc/dc/commit/d7c7302c12ac60f355ab9b3b1b461fcf7d0719b8))
|
||||
* (deps) bumped @sasjs/core, @sasjs/cli, @sasjs/utils and @sasjs/adapter ([af1657e](https://git.datacontroller.io/dc/dc/commit/af1657e226a4efd22cc87401a3850c4a665c2680))
|
||||
* configurable audit table on restore check ([26ce95f](https://git.datacontroller.io/dc/dc/commit/26ce95f7c1d2260f81c240cd6b058db154d997e4)), closes [#193](https://git.datacontroller.io/dc/dc/issues/193)
|
||||
* improved testing ([fb3c49a](https://git.datacontroller.io/dc/dc/commit/fb3c49aa8bfdc6acf2ae3034b885010dcdce32a6))
|
||||
* output values to intended macro variables ([43ae73c](https://git.datacontroller.io/dc/dc/commit/43ae73c5f3ad919394201f54984b61bb2a52fcfe))
|
||||
|
||||
## [7.2.4](https://git.datacontroller.io/dc/dc/compare/v7.2.3...v7.2.4) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensure reload after applying licence key ([cb1978b](https://git.datacontroller.io/dc/dc/commit/cb1978bcaf23b0bf45b5d3b78b9707fd4e48a5f4))
|
||||
* snyk report security patches ([387f512](https://git.datacontroller.io/dc/dc/commit/387f5122f1ea6dff55d23c9223f17737283a94d3))
|
||||
|
||||
## [7.2.3](https://git.datacontroller.io/dc/dc/compare/v7.2.2...v7.2.3) (2025-10-02)
|
||||
|
||||
|
||||
|
||||
+17
-32
@@ -41,6 +41,8 @@
|
||||
"zone.js",
|
||||
"text-encoding",
|
||||
"crypto-js/md5",
|
||||
"crypto-js/sha1",
|
||||
"crypto-js/sha512",
|
||||
"buffer",
|
||||
"numbro",
|
||||
"@clr/icons",
|
||||
@@ -51,26 +53,23 @@
|
||||
"base64-arraybuffer",
|
||||
"@handsontable/formulajs"
|
||||
],
|
||||
"polyfills": [
|
||||
"src/polyfills.ts",
|
||||
"zone.js"
|
||||
],
|
||||
"polyfills": ["src/polyfills.ts", "zone.js"],
|
||||
"outputPath": "dist",
|
||||
"resourcesOutputPath": "images",
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/images"
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "src/images",
|
||||
"output": "images",
|
||||
"ignore": ["spinner.svg", "caret.svg"]
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": [
|
||||
"node_modules/marked/marked.min.js"
|
||||
],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": ["node_modules/marked/marked.min.js"],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json",
|
||||
"main": "src/main.ts"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -103,9 +102,7 @@
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"vendorChunk": true,
|
||||
"extractLicenses": false,
|
||||
"buildOptimizer": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true
|
||||
@@ -134,20 +131,11 @@
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"src/polyfills.ts",
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"polyfills": ["src/polyfills.ts", "zone.js", "zone.js/testing"],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": ["src/styles.scss"],
|
||||
"scripts": [],
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
@@ -156,10 +144,7 @@
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-17
@@ -1,13 +1,13 @@
|
||||
import { defineConfig } from "cypress";
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
reporter: "mochawesome",
|
||||
reporter: 'mochawesome',
|
||||
|
||||
reporterOptions: {
|
||||
reportDir: "cypress/results",
|
||||
reportDir: 'cypress/results',
|
||||
overwrite: false,
|
||||
html: true,
|
||||
json: false,
|
||||
json: false
|
||||
},
|
||||
viewportHeight: 900,
|
||||
viewportWidth: 1600,
|
||||
@@ -16,24 +16,25 @@ export default defineConfig({
|
||||
defaultCommandTimeout: 30000,
|
||||
|
||||
env: {
|
||||
hosturl: "http://localhost:4200",
|
||||
appLocation: "",
|
||||
site_id_SAS9: "70221618",
|
||||
site_id_SASVIYA: "70253615",
|
||||
site_id_SASJS: "123",
|
||||
serverType: "SASJS",
|
||||
libraryToOpenIncludes_SASVIYA: "viya",
|
||||
libraryToOpenIncludes_SAS9: "dc",
|
||||
libraryToOpenIncludes_SASJS: "dc",
|
||||
hosturl: 'http://localhost:4200',
|
||||
appLocation: '',
|
||||
site_id_SAS9: '70221618',
|
||||
site_id_SASVIYA: '70253615',
|
||||
site_id_SASJS: '123',
|
||||
serverType: 'SASJS',
|
||||
libraryToOpenIncludes_SASVIYA: 'viya',
|
||||
libraryToOpenIncludes_SAS9: 'dc',
|
||||
libraryToOpenIncludes_SASJS: 'dc',
|
||||
debug: false,
|
||||
screenshotOnRunFailure: false,
|
||||
longerCommandTimeout: 50000,
|
||||
testLicenceUserLimits: false,
|
||||
testLicenceUserLimits: false
|
||||
},
|
||||
|
||||
e2e: {
|
||||
video: true,
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
// it ('? | Uploads Excel with size of 5MB', (done) => {
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
|
Binary file not shown.
Binary file not shown.
@@ -10,7 +10,7 @@ const check = (cwd) => {
|
||||
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;',
|
||||
excludePackages:
|
||||
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;handsontable@16.0.1;hyperformula@2.7.1;hyperformula@3.0.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'
|
||||
},
|
||||
(error, json) => {
|
||||
if (error) {
|
||||
|
||||
@@ -2,8 +2,8 @@ module.exports = {
|
||||
ci: {
|
||||
collect: {
|
||||
settings: {
|
||||
preset: "desktop",
|
||||
chromeFlags: "--no-sandbox --disable-dev-shm-usage"
|
||||
preset: 'desktop',
|
||||
chromeFlags: '--no-sandbox --disable-dev-shm-usage'
|
||||
},
|
||||
url: [
|
||||
'http://localhost:5000/AppStream/clickme/#/home/tables',
|
||||
@@ -37,6 +37,10 @@ module.exports = {
|
||||
{ minScore: 0.4, aggregationMethod: 'median' }
|
||||
]
|
||||
}
|
||||
},
|
||||
upload: {
|
||||
target: 'filesystem',
|
||||
outputDir: './lighthouse-reports'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+8898
-7019
File diff suppressed because it is too large
Load Diff
+38
-34
@@ -23,7 +23,7 @@
|
||||
"watch": "ng test watch=true",
|
||||
"pree2e": "webdriver-manager update",
|
||||
"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",
|
||||
"cypress": "cypress open",
|
||||
"cy:run": "cypress run",
|
||||
@@ -32,26 +32,27 @@
|
||||
"compodoc:build": "compodoc -p tsconfig.doc.json --name 'Data Controller Client'",
|
||||
"compodoc:build-and-serve": "compodoc -p tsconfig.doc.json -s --name 'Data Controller Client'",
|
||||
"compodoc:serve": "compodoc -s --name 'Data Controller Client'",
|
||||
"lighthouse": "lhci autorun"
|
||||
"lighthouse": "lhci autorun",
|
||||
"ng": "ng"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.3.3",
|
||||
"@angular/cdk": "^17.3.3",
|
||||
"@angular/common": "^17.3.3",
|
||||
"@angular/compiler": "^17.3.3",
|
||||
"@angular/core": "^17.3.3",
|
||||
"@angular/forms": "^17.3.3",
|
||||
"@angular/platform-browser": "^17.3.3",
|
||||
"@angular/platform-browser-dynamic": "^17.3.3",
|
||||
"@angular/router": "^17.3.3",
|
||||
"@angular/animations": "^19.2.20",
|
||||
"@angular/cdk": "^19.2.19",
|
||||
"@angular/common": "^19.2.20",
|
||||
"@angular/compiler": "^19.2.20",
|
||||
"@angular/core": "^19.2.20",
|
||||
"@angular/forms": "^19.2.20",
|
||||
"@angular/platform-browser": "^19.2.20",
|
||||
"@angular/platform-browser-dynamic": "^19.2.20",
|
||||
"@angular/router": "^19.2.20",
|
||||
"@cds/core": "^6.15.1",
|
||||
"@clr/angular": "file:libraries/clr-angular-17.9.0.tgz",
|
||||
"@clr/icons": "^13.0.2",
|
||||
"@clr/ui": "file:libraries/clr-ui-17.9.0.tgz",
|
||||
"@handsontable/angular-wrapper": "16.0.1",
|
||||
"@sasjs/adapter": "^4.12.2",
|
||||
"@sasjs/utils": "^3.4.0",
|
||||
"@handsontable/angular-wrapper": "^17.1.0",
|
||||
"@sasjs/adapter": "^4.17.0",
|
||||
"@sasjs/utils": "^3.5.3",
|
||||
"@sheet/crypto": "file:libraries/sheet-crypto.tgz",
|
||||
"@types/d3-graphviz": "^2.6.7",
|
||||
"@types/text-encoding": "0.0.35",
|
||||
@@ -61,14 +62,14 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3-graphviz": "^5.0.2",
|
||||
"fs-extra": "^7.0.1",
|
||||
"handsontable": "^16.0.1",
|
||||
"handsontable": "^17.1.0",
|
||||
"https-browserify": "1.0.0",
|
||||
"hyperformula": "^2.5.0",
|
||||
"iconv-lite": "^0.5.0",
|
||||
"jquery-datetimepicker": "^2.5.21",
|
||||
"jsrsasign": "^11.1.0",
|
||||
"jsrsasign": "11.1.1",
|
||||
"marked": "^5.0.0",
|
||||
"moment": "^2.26.0",
|
||||
"moment": "^2.30.1",
|
||||
"ngx-clipboard": "^16.0.0",
|
||||
"ngx-json-viewer": "file:libraries/ngx-json-viewer-3.2.1.tgz",
|
||||
"nodejs": "0.0.0",
|
||||
@@ -81,22 +82,22 @@
|
||||
"tslib": "^2.3.0",
|
||||
"vm": "^0.1.0",
|
||||
"webpack": "^5.91.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zone.js": "~0.14.4"
|
||||
"xlsx": "file:libraries/xlsx-0.20.3.tgz",
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.3.3",
|
||||
"@angular-eslint/builder": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "17.3.0",
|
||||
"@angular-eslint/schematics": "17.3.0",
|
||||
"@angular-eslint/template-parser": "17.3.0",
|
||||
"@angular/cli": "^17.3.3",
|
||||
"@angular/compiler-cli": "^17.3.3",
|
||||
"@angular-devkit/build-angular": "^19.2.24",
|
||||
"@angular-eslint/builder": "19.8.1",
|
||||
"@angular-eslint/eslint-plugin": "19.8.1",
|
||||
"@angular-eslint/eslint-plugin-template": "19.8.1",
|
||||
"@angular-eslint/schematics": "19.8.1",
|
||||
"@angular-eslint/template-parser": "19.8.1",
|
||||
"@angular/cli": "^19.2.24",
|
||||
"@angular/compiler-cli": "^19.2.20",
|
||||
"@babel/plugin-proposal-private-methods": "^7.18.6",
|
||||
"@compodoc/compodoc": "^1.1.21",
|
||||
"@compodoc/compodoc": "^1.2.1",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
"@lhci/cli": "^0.12.0",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@types/core-js": "^2.5.5",
|
||||
"@types/crypto-js": "^4.2.1",
|
||||
"@types/es6-shim": "^0.31.39",
|
||||
@@ -104,15 +105,15 @@
|
||||
"@types/lodash-es": "^4.17.3",
|
||||
"@types/marked": "^4.3.0",
|
||||
"@types/node": "12.20.50",
|
||||
"@typescript-eslint/eslint-plugin": "^5.29.0",
|
||||
"@typescript-eslint/parser": "^5.29.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"core-js": "^2.5.4",
|
||||
"cypress": "12.17.1",
|
||||
"cypress": "^15.14.2",
|
||||
"cypress-file-upload": "^5.0.8",
|
||||
"cypress-plugin-tab": "^1.0.5",
|
||||
"cypress-real-events": "^1.8.1",
|
||||
"es6-shim": "^0.35.5",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint": "8.57.1",
|
||||
"git-describe": "^4.0.4",
|
||||
"jasmine-core": "~5.1.2",
|
||||
"karma": "~6.4.3",
|
||||
@@ -128,8 +129,11 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-loader": "^9.2.8",
|
||||
"ts-node": "^3.3.0",
|
||||
"typescript": "~5.4.4",
|
||||
"typescript": "~5.8.3",
|
||||
"wait-on": "^6.0.1",
|
||||
"watch": "^1.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"ajv": "8.18.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
|
||||
*/
|
||||
export const globals: {
|
||||
embed: boolean
|
||||
rootParam: string
|
||||
dcLib: string
|
||||
xlmaps: XLMapListItem[]
|
||||
@@ -69,6 +70,7 @@ export const globals: {
|
||||
handsontable: HandsontableStaticConfig
|
||||
[key: string]: any
|
||||
} = {
|
||||
embed: false,
|
||||
rootParam: <string>'',
|
||||
dcLib: '',
|
||||
xlmaps: [],
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<header class="app-header">
|
||||
<header class="app-header" *ngIf="!embed">
|
||||
<!-- <button
|
||||
*ngIf="
|
||||
isMainRoute('view') ||
|
||||
@@ -213,9 +213,10 @@
|
||||
</header>
|
||||
<nav
|
||||
*ngIf="
|
||||
router.url.includes('submitted') ||
|
||||
router.url.includes('approve') ||
|
||||
router.url.includes('history')
|
||||
!embed &&
|
||||
(router.url.includes('submitted') ||
|
||||
router.url.includes('approve') ||
|
||||
router.url.includes('history'))
|
||||
"
|
||||
class="subnav"
|
||||
>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Location } from '@angular/common'
|
||||
import '@clr/icons'
|
||||
import '@clr/icons/shapes/all-shapes'
|
||||
import { globals } from './_globals'
|
||||
import * as moment from 'moment'
|
||||
import moment from 'moment'
|
||||
import { EventService } from './services/event.service'
|
||||
import { AppService } from './services/app.service'
|
||||
import { InfoModal } from './models/InfoModal'
|
||||
@@ -42,7 +42,8 @@ ClarityIcons.addIcons(
|
||||
selector: 'my-app',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class AppComponent {
|
||||
private dcAdapterSettings: DcAdapterSettings | undefined
|
||||
@@ -69,6 +70,7 @@ export class AppComponent {
|
||||
|
||||
public syssite = this.appService.syssite
|
||||
public licenceState = this.licenceService.licenceState
|
||||
public embed = globals.embed
|
||||
|
||||
constructor(
|
||||
private appService: AppService,
|
||||
@@ -142,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.subscribeToRequestsModal()
|
||||
this.subscribeToStartupData()
|
||||
@@ -197,6 +209,7 @@ export class AppComponent {
|
||||
dcPath: getAppAttribute('dcPath') || '',
|
||||
debug: getAppAttribute('debug') === 'true' || false,
|
||||
useComputeApi: this.parseComputeApi(getAppAttribute('useComputeApi')),
|
||||
runAsTask: getAppAttribute('runAsTask') === 'true' || false,
|
||||
contextName: getAppAttribute('contextName') || '',
|
||||
hotLicenceKey: getAppAttribute('hotLicenceKey') || ''
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
@@ -36,12 +36,12 @@ import { AppSettingsService } from './services/app-settings.service'
|
||||
InfoModalComponent,
|
||||
ViyaApiExplorerComponent
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
imports: [
|
||||
BrowserAnimationsModule,
|
||||
BrowserModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
HttpClientModule,
|
||||
ROUTING,
|
||||
SharedModule,
|
||||
ClarityModule,
|
||||
@@ -50,7 +50,12 @@ import { AppSettingsService } from './services/app-settings.service'
|
||||
DirectivesModule,
|
||||
NgxJsonViewerModule
|
||||
],
|
||||
providers: [AppService, SasStoreService, LicensingGuard, AppSettingsService],
|
||||
bootstrap: [AppComponent]
|
||||
providers: [
|
||||
AppService,
|
||||
SasStoreService,
|
||||
LicensingGuard,
|
||||
AppSettingsService,
|
||||
provideHttpClient(withInterceptorsFromDi())
|
||||
]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -14,7 +14,8 @@ import { DcAdapterSettings } from '../models/DcAdapterSettings'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class DeployComponent implements OnInit {
|
||||
public step: number = 0
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
selector: 'app-automatic-deploy',
|
||||
templateUrl: './automatic.component.html',
|
||||
styleUrls: ['./automatic.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class AutomaticComponent implements OnInit {
|
||||
@Input() sasJs!: SASjs
|
||||
@@ -372,7 +373,7 @@ export class AutomaticComponent implements OnInit {
|
||||
let contextname = `&_contextname=${params.contextName}`
|
||||
let admin = `&admin=${params.admin}`
|
||||
let dcPath = `&dcpath=${params.dcPath}`
|
||||
let debug = `&_debug=131`
|
||||
let debug = this.sasService.getDebugUrlParam()
|
||||
|
||||
let programUrl =
|
||||
serverUrl +
|
||||
|
||||
@@ -18,7 +18,8 @@ import { SasService } from 'src/app/services/sas.service'
|
||||
selector: 'app-manual-deploy',
|
||||
templateUrl: './manual.component.html',
|
||||
styleUrls: ['./manual.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class ManualComponent implements OnInit {
|
||||
@Input() sasJs!: SASjs
|
||||
@@ -250,7 +251,7 @@ export class ManualComponent implements OnInit {
|
||||
this.selectedAdminGroup +
|
||||
'&DCPATH=' +
|
||||
this.dcPath +
|
||||
'&_debug=131'
|
||||
this.sasService.getDebugUrlParam()
|
||||
|
||||
window.open(url, '_blank')
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ import { SasjsService } from 'src/app/services/sasjs.service'
|
||||
selector: 'app-sasjs-configurator',
|
||||
templateUrl: './sasjs-configurator.component.html',
|
||||
styleUrls: ['./sasjs-configurator.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class SasjsConfiguratorComponent implements OnInit {
|
||||
@Input() sasJs!: SASjs
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from '@angular/core'
|
||||
|
||||
@Directive({
|
||||
selector: '[appDragNdrop]'
|
||||
selector: '[appDragNdrop]',
|
||||
standalone: false
|
||||
})
|
||||
export class DragNdropDirective {
|
||||
@HostBinding('class.fileover') fileOver: boolean = false
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import { FileUploader } from '../models/FileUploader.class'
|
||||
|
||||
@Directive({
|
||||
selector: '[appFileDrop]'
|
||||
selector: '[appFileDrop]',
|
||||
standalone: false
|
||||
})
|
||||
export class FileDropDirective {
|
||||
@Input() uploader?: FileUploader
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import { FileUploader } from '../models/FileUploader.class'
|
||||
|
||||
@Directive({
|
||||
selector: '[appFileSelect]'
|
||||
selector: '[appFileSelect]',
|
||||
standalone: false
|
||||
})
|
||||
export class FileSelectDirective {
|
||||
@Input() uploader?: FileUploader
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core'
|
||||
* Calling functions in html is bad for performance
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[ngVar]'
|
||||
selector: '[ngVar]',
|
||||
standalone: false
|
||||
})
|
||||
export class NgVarDirective {
|
||||
@Input()
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
|
||||
@Directive({
|
||||
selector: '[appStealFocus]'
|
||||
selector: '[appStealFocus]',
|
||||
standalone: false
|
||||
})
|
||||
export class StealFocusDirective {
|
||||
constructor() {}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { HelperService } from 'src/app/services/helper.service'
|
||||
import { SasStoreService } from 'src/app/services/sas-store.service'
|
||||
import { DcValidator } from 'src/app/shared/dc-validator/dc-validator'
|
||||
import { DcValidation } from 'src/app/shared/dc-validator/models/dc-validation.model'
|
||||
import { isEmpty } from 'src/app/shared/dc-validator/utils/isEmpty'
|
||||
import {
|
||||
EditRecordDropdownChangeEvent,
|
||||
EditRecordInputFocusedEvent
|
||||
@@ -24,7 +25,8 @@ import { EditRecordModal } from '../../models/EditRecordModal'
|
||||
selector: 'app-edit-record',
|
||||
templateUrl: './edit-record.component.html',
|
||||
styleUrls: ['./edit-record.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class EditRecordComponent implements OnInit {
|
||||
@Input() currentRecord!: EditRecordModal
|
||||
@@ -99,7 +101,7 @@ export class EditRecordComponent implements OnInit {
|
||||
let format = cellValidation ? cellValidation.dateFormat : ''
|
||||
|
||||
if (this.currentRecord)
|
||||
this.currentRecord[colKey] = moment(date).format(format)
|
||||
this.currentRecord[colKey] = moment(date).format(format as string)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,23 +147,63 @@ export class EditRecordComponent implements OnInit {
|
||||
}, 0)
|
||||
}
|
||||
|
||||
async recordInputChange(event: any, colName: string) {
|
||||
async recordInputChange(event: any, colName: string): Promise<void> {
|
||||
const colRules = this.currentRecordValidator?.getRule(colName)
|
||||
const value = event.target.value
|
||||
|
||||
this.helperService.debounceCall(300, () => {
|
||||
this.validateRecordCol(colRules, value).then((valid: boolean) => {
|
||||
const index = this.currentRecordInvalidCols.indexOf(colName)
|
||||
this.updateValidationState(colName, valid)
|
||||
|
||||
if (valid) {
|
||||
if (index > -1) this.currentRecordInvalidCols.splice(index, 1)
|
||||
} else {
|
||||
if (index < 0) this.currentRecordInvalidCols.push(colName)
|
||||
if (!valid) {
|
||||
this.tryAutoPopulateNotNull(event, colName, colRules, value)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
this.onNextRecord.emit()
|
||||
}
|
||||
@@ -171,23 +213,8 @@ export class EditRecordComponent implements OnInit {
|
||||
}
|
||||
|
||||
public copyToClip(text: string) {
|
||||
const modalElement = document.querySelector('#recordModalRef .modal-title')
|
||||
|
||||
if (modalElement) {
|
||||
const selBox = document.createElement('textarea')
|
||||
selBox.style.position = 'fixed'
|
||||
selBox.style.left = '0'
|
||||
selBox.style.top = '0'
|
||||
selBox.style.opacity = '0'
|
||||
selBox.style.zIndex = '5000'
|
||||
selBox.value = text
|
||||
modalElement.appendChild(selBox)
|
||||
selBox.focus()
|
||||
selBox.select()
|
||||
document.execCommand('copy')
|
||||
modalElement.removeChild(selBox)
|
||||
this.generatedRecordUrl = text
|
||||
}
|
||||
navigator.clipboard.writeText(text)
|
||||
this.generatedRecordUrl = text
|
||||
}
|
||||
|
||||
async generateEditRecordUrl() {
|
||||
|
||||
@@ -11,7 +11,8 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
selector: 'app-upload-stater',
|
||||
templateUrl: './upload-stater.component.html',
|
||||
styleUrls: ['./upload-stater.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class UploadStaterComponent implements OnInit {
|
||||
public statesList: string[] = [] //States appended to be displayed
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
class="card-header clr-row buttonBar headerBar clr-flex-md-row clr-justify-content-center clr-justify-content-lg-end"
|
||||
>
|
||||
<div
|
||||
*ngIf="tableTrue"
|
||||
*ngIf="tableTrue && !embed"
|
||||
class="clr-col-12 clr-col-md-3 clr-col-lg-4 backBtn"
|
||||
>
|
||||
<span
|
||||
@@ -873,3 +873,17 @@
|
||||
</app-dataset-info>
|
||||
|
||||
<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 Handsontable from 'handsontable'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { sanitiseForSas } from '../shared/utils/sanitise'
|
||||
import { SasStoreService } from '../services/sas-store.service'
|
||||
|
||||
type AOA = any[][]
|
||||
@@ -43,6 +44,8 @@ import { Col } from '../shared/dc-validator/models/col.model'
|
||||
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
|
||||
import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
|
||||
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 { UploadStaterComponent } from './components/upload-stater/upload-stater.component'
|
||||
import { DynamicExtendedCellValidation } from './models/dynamicExtendedCellValidation'
|
||||
@@ -70,7 +73,8 @@ import { ParseResult } from '../models/ParseResult.interface'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@ViewChildren('uploadStater')
|
||||
@@ -130,7 +134,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
licenseKey: this.hotTable.licenseKey,
|
||||
readOnly: this.hotTable.readOnly,
|
||||
copyPaste: this.hotTable.copyPaste,
|
||||
contextMenu: true
|
||||
contextMenu: true,
|
||||
className: 'htDark',
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +268,9 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
public badEdit = false
|
||||
public badEditCause: string | undefined
|
||||
public badEditTitle: string | undefined
|
||||
get embed() {
|
||||
return globals.embed
|
||||
}
|
||||
public tableTrue: boolean | undefined
|
||||
public saveLoading = false
|
||||
public approvers: string[] = []
|
||||
@@ -351,7 +360,29 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* Hash/values table used for dynamic cell validation
|
||||
*/
|
||||
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
|
||||
public extendedCellValidationFields: {
|
||||
DISPLAY_INDEX: number
|
||||
EXTRA_COL_NAME: number
|
||||
@@ -957,6 +988,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this.cancelBulkValidation({ revert: false })
|
||||
|
||||
this.toggleHotPlugin('contextMenu', false)
|
||||
|
||||
this.cellValidationSource = []
|
||||
@@ -986,7 +1019,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
false
|
||||
)
|
||||
|
||||
hot.validateRows(this.modifedRowsIndexes)
|
||||
this.modifedRowsIndexes = []
|
||||
hot.validateCells()
|
||||
// this.editRecordListeners();
|
||||
for (const sortConfig of sortConfigs) {
|
||||
columnSorting.sort(sortConfig)
|
||||
@@ -995,6 +1029,160 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
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
|
||||
public hotClicked() {
|
||||
if (this.timesClicked === 1 && this.hotTable.readOnly) {
|
||||
@@ -1044,12 +1232,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 {
|
||||
const newRow: any = {}
|
||||
this.headerColumns.forEach((col: string) => {
|
||||
newRow[col] = ''
|
||||
this.cellValidation.forEach((rule: any) => {
|
||||
const dataKey = rule.data
|
||||
newRow[dataKey] = this.hotDataSchema.hasOwnProperty(dataKey)
|
||||
? this.hotDataSchema[dataKey]
|
||||
: ''
|
||||
})
|
||||
newRow['noLinkOption'] = true
|
||||
return newRow
|
||||
@@ -1660,7 +1852,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.submit = true
|
||||
const updateParams: any = {}
|
||||
updateParams.ACTION = 'LOAD'
|
||||
this.message = this.message.replace(/\n/g, '. ')
|
||||
this.message = sanitiseForSas(this.message.replace(/\n/g, '. '))
|
||||
updateParams.MESSAGE = this.message
|
||||
// updateParams.APPROVER = this.approver;
|
||||
updateParams.LIBDS = this.libds
|
||||
@@ -1959,18 +2151,26 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* @param row handsontable row
|
||||
* @param column handsontable column
|
||||
*/
|
||||
public dynamicCellValidation(row: number, column: number) {
|
||||
if (this.dynamicCellValidationDisabled(row, column)) return
|
||||
public async dynamicCellValidation(
|
||||
row: number,
|
||||
column: number,
|
||||
opts?: { skipRender?: boolean },
|
||||
retried = false
|
||||
): Promise<void> {
|
||||
if (this.dynamicCellValidationDisabled(row, column))
|
||||
return Promise.resolve()
|
||||
|
||||
const hot = this.hotInstance
|
||||
|
||||
const cellMeta = hot.getCellMeta(row, column)
|
||||
|
||||
if (cellMeta.readOnly) return
|
||||
if (cellMeta.readOnly) return Promise.resolve()
|
||||
|
||||
const cellData = hot.getDataAtCell(row, column)
|
||||
const clickedRow = this.helperService.deepClone(this.dataSource[row])
|
||||
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
|
||||
@@ -1991,6 +2191,22 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* 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) {
|
||||
let colSource = this.cellValidationSource[
|
||||
validationSourceIndex
|
||||
@@ -2066,14 +2282,12 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
cellHadSource && cellHasValue
|
||||
)
|
||||
|
||||
hot.render()
|
||||
if (!skipRender) hot.render()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request to sas.
|
||||
*/
|
||||
if (validationSourceIndex < 0) {
|
||||
} else if (validationSourceIndex < 0) {
|
||||
/**
|
||||
* Send request to sas.
|
||||
*/
|
||||
const data = {
|
||||
SASControlTable: [
|
||||
{
|
||||
@@ -2105,21 +2319,53 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
count: this.cellValidationSource.length + 1
|
||||
})
|
||||
|
||||
hot.setCellMeta(row, column, 'renderer', spinnerRenderer)
|
||||
|
||||
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, {
|
||||
suppressSuccessAbortModal: 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(
|
||||
(el: any) => el[this.cellValidationFields.RAW_VALUE]
|
||||
)
|
||||
|
||||
this.currentEditRecordLoadings.splice(
|
||||
this.currentEditRecordLoadings.indexOf(column),
|
||||
1
|
||||
)
|
||||
|
||||
if (colSource.length > 0) {
|
||||
const validationSourceIndex = this.cellValidationSource.findIndex(
|
||||
(entry: CellValidationSource) => entry.hash === hashedRow
|
||||
@@ -2131,49 +2377,37 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
row: row,
|
||||
col: column,
|
||||
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
|
||||
* we need to update it here after we set new `cellValidationSource` (validation lookup hash table) values
|
||||
* so that it will check those values to decide whether numeric cells should be
|
||||
* converted to the dropdown
|
||||
* 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
|
||||
*/
|
||||
|
||||
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
|
||||
*/
|
||||
await new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
this.reSetCellValidationValues(true, row)
|
||||
hot.render()
|
||||
|
||||
hot.validateRows([row])
|
||||
if (!skipRender) {
|
||||
hot.render()
|
||||
hot.validateRows([row])
|
||||
}
|
||||
resolve()
|
||||
}, 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.
|
||||
* Oldest element is element with lowest `count` number.
|
||||
@@ -2189,18 +2423,25 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (spinnerTimeout) clearTimeout(spinnerTimeout)
|
||||
this.pendingSpinnerCells.delete(spinnerKey)
|
||||
|
||||
const currentRowHashIndex = this.cellValidationSource.findIndex(
|
||||
(x) => x.hash === hashedRow
|
||||
)
|
||||
|
||||
this.cellValidationSource.splice(currentRowHashIndex, 1)
|
||||
|
||||
hot.batch(() => {
|
||||
// Render error icon inside a cell
|
||||
hot.setCellMeta(row, column, 'renderer', errorRenderer)
|
||||
if (myEpoch !== this.validationEpoch) return
|
||||
|
||||
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
|
||||
this.currentEditRecordLoadings.splice(
|
||||
@@ -2213,8 +2454,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
// After waiting time remove the error icon from cell and edit record modal field
|
||||
setTimeout(() => {
|
||||
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
|
||||
hot.render()
|
||||
if (!skipRender) {
|
||||
hot.setCellMeta(row, column, 'renderer', noSpinnerRenderer)
|
||||
hot.render()
|
||||
}
|
||||
|
||||
//Remove error icon on the edit record modal field
|
||||
this.currentEditRecordErrors.splice(
|
||||
@@ -2227,8 +2470,19 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
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() {
|
||||
@@ -2675,13 +2929,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// Note: this.headerColumns and this.columnHeader contains same data
|
||||
// need to resolve redundancy
|
||||
|
||||
// default schema
|
||||
// default schema - includes NOTNULL defaults from DQ rules
|
||||
for (let i = 0; i < this.headerColumns.length; i++) {
|
||||
const colType = this.cellValidation[i].type
|
||||
|
||||
this.hotDataSchema[this.cellValidation[i].data] = getHotDataSchema(
|
||||
colType,
|
||||
this.cellValidation[i]
|
||||
this.cellValidation[i],
|
||||
this.dcValidator?.getDqDetails()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2927,6 +3182,31 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
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) => {
|
||||
if (change === 'edit') {
|
||||
const hot = this.hotInstance
|
||||
@@ -2945,6 +3225,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) => {
|
||||
this.eventService.dispatchEvent('resize')
|
||||
|
||||
@@ -2986,21 +3298,62 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
)
|
||||
|
||||
hot.addHook('beforePaste', (data: any, cords: any) => {
|
||||
const startCol = cords[0].startCol
|
||||
// Auto-populate NOTNULL default when validation fails due to empty value
|
||||
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
|
||||
data[0] = data[0].map((value: any, index: number) => {
|
||||
const colName =
|
||||
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 isColNum = this.$dataFormats?.vars[colName]?.type === 'num'
|
||||
const specialMissing = isSpecialMissing(value)
|
||||
|
||||
if (isColNum && !isNaN(value) && !specialMissing) value = value * 1
|
||||
|
||||
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', () => {
|
||||
this.checkRowLimit()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
* Used to show error icon
|
||||
|
||||
@@ -30,7 +30,7 @@ export const freeTierConfig: LicenceState = {
|
||||
lineage_daily_limit: 3,
|
||||
tables_in_library_limit: 35,
|
||||
viewbox: true,
|
||||
fileUpload: true,
|
||||
fileUpload: false,
|
||||
editRecord: true,
|
||||
addRecord: true
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapper
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class GroupComponent implements OnInit {
|
||||
public groups: Array<any> | undefined
|
||||
|
||||
@@ -19,7 +19,8 @@ import { LicenceService } from '../services/licence.service'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class HomeComponent implements AfterContentInit {
|
||||
public treeNodeLibraries: Array<any> | null = null
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<p><strong>Protocol:</strong> {{ protocol }}</p>
|
||||
|
||||
<p>
|
||||
<strong>SYSSITE:</strong>
|
||||
<span
|
||||
|
||||
@@ -1,178 +1,186 @@
|
||||
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AppService, LicenceService, SasService } from '../services'
|
||||
import { LicenseKeyData } from '../models/LicenseKeyData'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
|
||||
enum LicenseActions {
|
||||
key = 'key',
|
||||
register = 'register',
|
||||
limit = 'limit',
|
||||
update = 'update'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-licensing',
|
||||
templateUrl: './licensing.component.html',
|
||||
styleUrls: ['./licensing.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
})
|
||||
export class LicensingComponent implements OnInit {
|
||||
public action: LicenseActions | null = null
|
||||
|
||||
public licenseErrors: { [key: string]: string } = {
|
||||
missing: `Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
expired: `Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
invalid: `Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
missmatch: `Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
|
||||
}
|
||||
|
||||
public keyError: string | undefined
|
||||
public errorDetails: string | undefined
|
||||
public missmatchedKey: string | undefined
|
||||
public licenceKeyValue: string = ''
|
||||
public activationKeyValue: string = ''
|
||||
|
||||
public applyingKeys: boolean = false
|
||||
|
||||
public syssite = this.appService.syssite
|
||||
public currentLicenceKey = this.licenceService.licenceKey
|
||||
public currentActivationKey = this.licenceService.activationKey
|
||||
public isAppFreeTier = this.licenceService.isAppFreeTier
|
||||
public userCountLimitation = this.licenceService.userCountLimitation
|
||||
|
||||
public licenseKeyData: LicenseKeyData | null = null
|
||||
|
||||
public inputType: 'file' | 'paste' = 'file'
|
||||
public licenceFileError: string | undefined
|
||||
public licenceFileLoading: boolean = false
|
||||
public licencefile: { filename: string } = {
|
||||
filename: ''
|
||||
}
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private licenceService: LicenceService,
|
||||
private sasService: SasService,
|
||||
private appService: AppService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.licenceKeyValue = this.currentLicenceKey || ''
|
||||
this.activationKeyValue = this.currentActivationKey || ''
|
||||
|
||||
this.route.queryParams.subscribe((queryParams: any) => {
|
||||
this.keyError = queryParams.error
|
||||
this.missmatchedKey = queryParams.missmatchId
|
||||
|
||||
if (queryParams.details) {
|
||||
this.errorDetails = atob(queryParams.details)
|
||||
}
|
||||
})
|
||||
|
||||
this.route.params.subscribe((params: any) => {
|
||||
let actionInUrl = params.action
|
||||
|
||||
if (actionInUrl) {
|
||||
if (Object.values(LicenseActions).includes(actionInUrl)) {
|
||||
this.action = actionInUrl
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.licenseKeyData = this.licenceService.getLicenseKeyData()
|
||||
}
|
||||
|
||||
public trimKeys() {
|
||||
this.licenceKeyValue = this.licenceKeyValue.trim()
|
||||
this.activationKeyValue = this.activationKeyValue.trim()
|
||||
}
|
||||
|
||||
public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
|
||||
const syssiteString = syssite.join('\n')
|
||||
|
||||
navigator.clipboard.writeText(syssiteString).then(() => {
|
||||
copyIconRef.setAttribute('shape', 'check')
|
||||
copyIconRef.setAttribute('class', 'is-success')
|
||||
copyTooltip.innerText = 'Copied!'
|
||||
|
||||
setTimeout(() => {
|
||||
copyIconRef.setAttribute('shape', 'copy')
|
||||
copyIconRef.removeAttribute('class')
|
||||
copyTooltip.innerText = 'Copy to clipboard'
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
public applyKeys() {
|
||||
this.applyingKeys = true
|
||||
|
||||
let table = {
|
||||
keyupload: [
|
||||
{
|
||||
ACTIVATION_KEY: this.activationKeyValue,
|
||||
LICENCE_KEY: this.licenceKeyValue
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.sasService
|
||||
.request('admin/registerkey', table)
|
||||
.then((res: RequestWrapperResponse) => {
|
||||
if (
|
||||
res.adapterResponse.return &&
|
||||
res.adapterResponse.return[0] &&
|
||||
res.adapterResponse.return[0].MSG === 'SUCCESS'
|
||||
) {
|
||||
location.replace(location.href.split('#')[0])
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.applyingKeys = false
|
||||
})
|
||||
}
|
||||
|
||||
public onFileCapture(event: any, dropped = false) {
|
||||
let file = dropped ? event[0] : event.target.files[0]
|
||||
this.licencefile.filename = file.name
|
||||
|
||||
if (!file) return
|
||||
|
||||
this.licenceFileLoading = true
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (evt) => {
|
||||
this.licenceFileError = 'Error reading file.'
|
||||
|
||||
if (!evt || !evt.target) return
|
||||
if (evt.target.readyState != 2) return
|
||||
if (evt.target.error) return
|
||||
if (!evt.target.result) return
|
||||
|
||||
this.licenceFileLoading = false
|
||||
this.licenceFileError = undefined
|
||||
const fileArr = evt.target.result.toString().split('\n')
|
||||
this.activationKeyValue = fileArr[1]
|
||||
this.licenceKeyValue = fileArr[0]
|
||||
}
|
||||
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
public switchType(type: 'paste' | 'file') {
|
||||
this.inputType = type
|
||||
}
|
||||
|
||||
get disableApplyButton(): boolean {
|
||||
if (this.licenceKeyValue.length < 1 || this.activationKeyValue.length < 1)
|
||||
return true
|
||||
if (
|
||||
this.licenceKeyValue === this.currentLicenceKey &&
|
||||
this.activationKeyValue === this.currentActivationKey
|
||||
)
|
||||
return true
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { AppService, LicenceService, SasService } from '../services'
|
||||
import { LicenseKeyData } from '../models/LicenseKeyData'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
|
||||
enum LicenseActions {
|
||||
key = 'key',
|
||||
register = 'register',
|
||||
limit = 'limit',
|
||||
update = 'update'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-licensing',
|
||||
templateUrl: './licensing.component.html',
|
||||
styleUrls: ['./licensing.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class LicensingComponent implements OnInit {
|
||||
public action: LicenseActions | null = null
|
||||
|
||||
public licenseErrors: { [key: string]: string } = {
|
||||
missing: `Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
expired: `Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
invalid: `Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
|
||||
missmatch: `Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
|
||||
}
|
||||
|
||||
public keyError: string | undefined
|
||||
public errorDetails: string | undefined
|
||||
public missmatchedKey: string | undefined
|
||||
public licenceKeyValue: string = ''
|
||||
public activationKeyValue: string = ''
|
||||
|
||||
public applyingKeys: boolean = false
|
||||
public protocol: string =
|
||||
location.protocol === 'https:'
|
||||
? 'HTTPS - secure connection'
|
||||
: 'HTTP - insecure connection'
|
||||
|
||||
public syssite = this.appService.syssite
|
||||
public currentLicenceKey = this.licenceService.licenceKey
|
||||
public currentActivationKey = this.licenceService.activationKey
|
||||
public isAppFreeTier = this.licenceService.isAppFreeTier
|
||||
public userCountLimitation = this.licenceService.userCountLimitation
|
||||
|
||||
public licenseKeyData: LicenseKeyData | null = null
|
||||
|
||||
public inputType: 'file' | 'paste' = 'file'
|
||||
public licenceFileError: string | undefined
|
||||
public licenceFileLoading: boolean = false
|
||||
public licencefile: { filename: string } = {
|
||||
filename: ''
|
||||
}
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private licenceService: LicenceService,
|
||||
private sasService: SasService,
|
||||
private appService: AppService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.licenceKeyValue = this.currentLicenceKey || ''
|
||||
this.activationKeyValue = this.currentActivationKey || ''
|
||||
|
||||
this.route.queryParams.subscribe((queryParams: any) => {
|
||||
this.keyError = queryParams.error
|
||||
this.missmatchedKey = queryParams.missmatchId
|
||||
|
||||
if (queryParams.details) {
|
||||
this.errorDetails = atob(queryParams.details)
|
||||
}
|
||||
})
|
||||
|
||||
this.route.params.subscribe((params: any) => {
|
||||
let actionInUrl = params.action
|
||||
|
||||
if (actionInUrl) {
|
||||
if (Object.values(LicenseActions).includes(actionInUrl)) {
|
||||
this.action = actionInUrl
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.licenseKeyData = this.licenceService.getLicenseKeyData()
|
||||
}
|
||||
|
||||
public trimKeys() {
|
||||
this.licenceKeyValue = this.licenceKeyValue.trim()
|
||||
this.activationKeyValue = this.activationKeyValue.trim()
|
||||
}
|
||||
|
||||
public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
|
||||
const syssiteString = syssite.join('\n')
|
||||
|
||||
navigator.clipboard.writeText(syssiteString).then(() => {
|
||||
copyIconRef.setAttribute('shape', 'check')
|
||||
copyIconRef.setAttribute('class', 'is-success')
|
||||
copyTooltip.innerText = 'Copied!'
|
||||
|
||||
setTimeout(() => {
|
||||
copyIconRef.setAttribute('shape', 'copy')
|
||||
copyIconRef.removeAttribute('class')
|
||||
copyTooltip.innerText = 'Copy to clipboard'
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
public applyKeys() {
|
||||
this.applyingKeys = true
|
||||
|
||||
let table = {
|
||||
keyupload: [
|
||||
{
|
||||
ACTIVATION_KEY: this.activationKeyValue,
|
||||
LICENCE_KEY: this.licenceKeyValue
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.sasService
|
||||
.request('admin/registerkey', table)
|
||||
.then((res: RequestWrapperResponse) => {
|
||||
if (
|
||||
res.adapterResponse.return &&
|
||||
res.adapterResponse.return[0] &&
|
||||
res.adapterResponse.return[0].MSG === 'SUCCESS'
|
||||
) {
|
||||
this.router.navigateByUrl('/').then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.applyingKeys = false
|
||||
})
|
||||
}
|
||||
|
||||
public onFileCapture(event: any, dropped = false) {
|
||||
let file = dropped ? event[0] : event.target.files[0]
|
||||
this.licencefile.filename = file.name
|
||||
|
||||
if (!file) return
|
||||
|
||||
this.licenceFileLoading = true
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (evt) => {
|
||||
this.licenceFileError = 'Error reading file.'
|
||||
|
||||
if (!evt || !evt.target) return
|
||||
if (evt.target.readyState != 2) return
|
||||
if (evt.target.error) return
|
||||
if (!evt.target.result) return
|
||||
|
||||
this.licenceFileLoading = false
|
||||
this.licenceFileError = undefined
|
||||
const fileArr = evt.target.result.toString().split('\n')
|
||||
this.activationKeyValue = fileArr[1]
|
||||
this.licenceKeyValue = fileArr[0]
|
||||
}
|
||||
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
public switchType(type: 'paste' | 'file') {
|
||||
this.inputType = type
|
||||
}
|
||||
|
||||
get disableApplyButton(): boolean {
|
||||
if (this.licenceKeyValue.length < 1 || this.activationKeyValue.length < 1)
|
||||
return true
|
||||
if (
|
||||
this.licenceKeyValue === this.currentLicenceKey &&
|
||||
this.activationKeyValue === this.currentActivationKey
|
||||
)
|
||||
return true
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,13 +239,7 @@
|
||||
|
||||
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
||||
<div (click)="downloadSVG()" clrDropdownItem>SVG</div>
|
||||
<div
|
||||
*ngIf="!helperService.isMicrosoft"
|
||||
(click)="downloadPNG()"
|
||||
clrDropdownItem
|
||||
>
|
||||
PNG
|
||||
</div>
|
||||
<div (click)="downloadPNG()" clrDropdownItem>PNG</div>
|
||||
<div (click)="downloadDot()" clrDropdownItem>Dot</div>
|
||||
<div *ngIf="flatdata" (click)="downloadCSV()" clrDropdownItem>
|
||||
CSV
|
||||
@@ -366,13 +360,7 @@
|
||||
|
||||
<clr-dropdown-menu clrPosition="bottom-left" *clrIfOpen>
|
||||
<div (click)="renderToDownload('SVG')" clrDropdownItem>SVG</div>
|
||||
<div
|
||||
*ngIf="!helperService.isMicrosoft"
|
||||
(click)="renderToDownload('PNG')"
|
||||
clrDropdownItem
|
||||
>
|
||||
PNG
|
||||
</div>
|
||||
<div (click)="renderToDownload('PNG')" clrDropdownItem>PNG</div>
|
||||
<div (click)="downloadDot(); cancelRenderingGraph()" clrDropdownItem>
|
||||
Dot
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,8 @@ const moment = require('moment')
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class LineageComponent {
|
||||
public switchFlag: boolean = false
|
||||
@@ -746,28 +747,13 @@ export class LineageComponent {
|
||||
return URL.createObjectURL(svg_blob)
|
||||
}
|
||||
|
||||
private getSVGBlob() {
|
||||
let svg: any = document.getElementById('graph')
|
||||
let serializer = new XMLSerializer()
|
||||
let svg_blob = new Blob([serializer.serializeToString(svg)], {
|
||||
type: 'image/svg+xml'
|
||||
})
|
||||
return svg_blob
|
||||
}
|
||||
|
||||
downloadSVG() {
|
||||
d3Viz.graphviz('#graph').resetZoom()
|
||||
|
||||
if (navigator.appVersion.toString().indexOf('.NET') > 0) {
|
||||
window.navigator.msSaveBlob(this.getSVGBlob(), this.constructName('svg'))
|
||||
} else {
|
||||
let downloadLink = document.createElement('a')
|
||||
downloadLink.href = this.getSVGURL()
|
||||
downloadLink.download = this.constructName('svg')
|
||||
document.body.appendChild(downloadLink)
|
||||
downloadLink.click()
|
||||
document.body.removeChild(downloadLink)
|
||||
}
|
||||
let downloadLink = document.createElement('a')
|
||||
downloadLink.href = this.getSVGURL()
|
||||
downloadLink.download = this.constructName('svg')
|
||||
downloadLink.click()
|
||||
}
|
||||
|
||||
async downloadPNG() {
|
||||
@@ -795,16 +781,11 @@ export class LineageComponent {
|
||||
var a = document.createElement('a')
|
||||
var blob = new Blob([csvArray], { type: 'text/csv' })
|
||||
|
||||
if (navigator.appVersion.toString().indexOf('.NET') > 0) {
|
||||
window.navigator.msSaveBlob(blob, this.constructName('csv'))
|
||||
} else {
|
||||
var url = window.URL.createObjectURL(blob)
|
||||
a.href = url
|
||||
a.download = this.constructName('csv')
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
a.remove()
|
||||
}
|
||||
var url = window.URL.createObjectURL(blob)
|
||||
a.href = url
|
||||
a.download = this.constructName('csv')
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
private getDotUrl() {
|
||||
@@ -813,23 +794,11 @@ export class LineageComponent {
|
||||
return window.URL.createObjectURL(dot_blob)
|
||||
}
|
||||
|
||||
private getDotBlob() {
|
||||
let data = this.vizInput
|
||||
let dot_blob = new Blob([data], { type: 'text/plain' })
|
||||
return dot_blob
|
||||
}
|
||||
|
||||
downloadDot() {
|
||||
if (navigator.appVersion.toString().indexOf('.NET') > 0) {
|
||||
window.navigator.msSaveBlob(this.getDotBlob(), this.constructName('txt'))
|
||||
} else {
|
||||
let downloadLink = document.createElement('a')
|
||||
downloadLink.href = this.getDotUrl()
|
||||
downloadLink.download = this.constructName('txt')
|
||||
document.body.appendChild(downloadLink)
|
||||
downloadLink.click()
|
||||
document.body.removeChild(downloadLink)
|
||||
}
|
||||
let downloadLink = document.createElement('a')
|
||||
downloadLink.href = this.getDotUrl()
|
||||
downloadLink.download = this.constructName('txt')
|
||||
downloadLink.click()
|
||||
}
|
||||
|
||||
public showSvg() {
|
||||
|
||||
@@ -51,7 +51,8 @@ class ValueFilter implements ClrDatagridStringFilterInterface<MetaData> {
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class MetadataComponent implements OnInit {
|
||||
metaDataList: Array<any> | undefined
|
||||
|
||||
@@ -6,4 +6,5 @@ export interface CellValidationSource {
|
||||
extended_values?: string[]
|
||||
hash: string
|
||||
count: number
|
||||
pending?: Promise<void>
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ enum FileLoadingState {
|
||||
selector: 'app-multi-dataset',
|
||||
templateUrl: './multi-dataset.component.html',
|
||||
styleUrls: ['./multi-dataset.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
@HostBinding('class.content-container') contentContainerClass = true
|
||||
@@ -159,14 +160,16 @@ export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
||||
filters: true,
|
||||
stretchH: 'all',
|
||||
afterGetColHeader: baseAfterGetColHeader,
|
||||
modifyColWidth: this.maxWidthCheker
|
||||
modifyColWidth: this.maxWidthCheker,
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
|
||||
// Exclude data from settings for HOT v16 - it will be loaded manually
|
||||
const { data, ...settingsWithoutData } = this.hotUserDatasets
|
||||
this.hotUserDatasetsSettings = {
|
||||
...settingsWithoutData,
|
||||
licenseKey: this.hotTableLicenseKey
|
||||
licenseKey: this.hotTableLicenseKey,
|
||||
theme: 'ht-theme-classic'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class NotFoundComponent implements OnInit {
|
||||
constructor() {}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { bytesToSize } from '@sasjs/utils/utils/bytesToSize'
|
||||
|
||||
@Pipe({
|
||||
name: 'convertSize'
|
||||
name: 'convertSize',
|
||||
standalone: false
|
||||
})
|
||||
export class ConvertSizePipe implements PipeTransform {
|
||||
transform(bytes: string | number, ...args: string[]): string {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import * as moment from 'moment'
|
||||
import moment from 'moment'
|
||||
@Pipe({
|
||||
name: 'dateTimeFormatter'
|
||||
name: 'dateTimeFormatter',
|
||||
standalone: false
|
||||
})
|
||||
export class DateTimeFormatterPipe implements PipeTransform {
|
||||
transform(value: Date | string, type: string): string {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
@Pipe({
|
||||
name: 'linkinze'
|
||||
name: 'linkinze',
|
||||
standalone: false
|
||||
})
|
||||
export class LinkinzePipe implements PipeTransform {
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { HelperService } from '../services/helper.service'
|
||||
|
||||
@Pipe({
|
||||
name: 'sasToJsDate'
|
||||
name: 'sasToJsDate',
|
||||
standalone: false
|
||||
})
|
||||
export class sasToJsDatePipe implements PipeTransform {
|
||||
constructor(private helperService: HelperService) {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
@Pipe({
|
||||
name: 'pkSpaceSeparate'
|
||||
name: 'pkSpaceSeparate',
|
||||
standalone: false
|
||||
})
|
||||
export class PkSpaceSeparatePipe implements PipeTransform {
|
||||
transform(value: string): string {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
@Pipe({
|
||||
name: 'prettyjson'
|
||||
name: 'prettyjson',
|
||||
standalone: false
|
||||
})
|
||||
export class PrettyjsonPipe implements PipeTransform {
|
||||
transform(rawJson: any): string {
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { HelperService } from '../services/helper.service'
|
||||
|
||||
@Pipe({
|
||||
name: 'secondsParser'
|
||||
name: 'secondsParser',
|
||||
standalone: false
|
||||
})
|
||||
export class SecondsParserPipe implements PipeTransform {
|
||||
constructor(private helperService: HelperService) {}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
@Pipe({
|
||||
name: 'thousandSeparator'
|
||||
name: 'thousandSeparator',
|
||||
standalone: false
|
||||
})
|
||||
export class ThousandSeparatorPipe implements PipeTransform {
|
||||
transform(value: string | number, separator?: string): string {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
@Pipe({
|
||||
name: 'toNumber'
|
||||
name: 'toNumber',
|
||||
standalone: false
|
||||
})
|
||||
export class ToNumberPipe implements PipeTransform {
|
||||
transform(value: string | number): number {
|
||||
|
||||
@@ -29,7 +29,8 @@ registerLocaleData(localeEnGB)
|
||||
templateUrl: './query.component.html',
|
||||
styleUrls: ['./query.component.scss'],
|
||||
providers: [{ provide: LOCALE_ID, useValue: 'en-GB' }],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class QueryComponent
|
||||
implements AfterViewInit, AfterContentInit, OnDestroy
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { sanitiseForSas } from '../../shared/utils/sanitise'
|
||||
import { SasStoreService } from '../../services/sas-store.service'
|
||||
import {
|
||||
Component,
|
||||
@@ -30,7 +31,8 @@ interface ChangesObj {
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class ApproveDetailsComponent implements AfterViewInit, OnDestroy {
|
||||
private _detailsSub: Subscription | undefined
|
||||
@@ -135,7 +137,7 @@ export class ApproveDetailsComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
public async rejecting() {
|
||||
this.rejectLoading = true
|
||||
this.submitReason = this.submitReason.replace(/\n/g, '. ')
|
||||
this.submitReason = sanitiseForSas(this.submitReason.replace(/\n/g, '. '))
|
||||
|
||||
let rejParams = {
|
||||
STP_ACTION: 'REJECT_TABLE',
|
||||
|
||||
@@ -38,9 +38,7 @@ class SubmittedFilter implements ClrDatagridStringFilterInterface<ApproveData> {
|
||||
}
|
||||
}
|
||||
|
||||
class SubmitReasonFilter
|
||||
implements ClrDatagridStringFilterInterface<ApproveData>
|
||||
{
|
||||
class SubmitReasonFilter implements ClrDatagridStringFilterInterface<ApproveData> {
|
||||
accepts(data: ApproveData, search: string): boolean {
|
||||
return data.submitReason.toLowerCase().indexOf(search.toLowerCase()) >= 0
|
||||
}
|
||||
@@ -53,7 +51,8 @@ class SubmitReasonFilter
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class ApproveComponent implements OnInit {
|
||||
public approveList: Array<ApproveData> | undefined
|
||||
|
||||
@@ -38,9 +38,7 @@ class SubmitterFilter implements ClrDatagridStringFilterInterface<HistoryData> {
|
||||
}
|
||||
}
|
||||
|
||||
class SubmitReasonFilter
|
||||
implements ClrDatagridStringFilterInterface<HistoryData>
|
||||
{
|
||||
class SubmitReasonFilter implements ClrDatagridStringFilterInterface<HistoryData> {
|
||||
accepts(data: HistoryData, search: string): boolean {
|
||||
return data.submittedReason.toLowerCase().indexOf(search.toLowerCase()) >= 0
|
||||
}
|
||||
@@ -65,7 +63,8 @@ class ReviewedFilter implements ClrDatagridStringFilterInterface<HistoryData> {
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class HistoryComponent implements OnInit {
|
||||
public history: Array<any> = []
|
||||
|
||||
@@ -17,17 +17,13 @@ interface SubmitterData {
|
||||
approver: string
|
||||
}
|
||||
|
||||
class SubmittedFilter
|
||||
implements ClrDatagridStringFilterInterface<SubmitterData>
|
||||
{
|
||||
class SubmittedFilter implements ClrDatagridStringFilterInterface<SubmitterData> {
|
||||
accepts(data: SubmitterData, search: string): boolean {
|
||||
return data.submitted.toLowerCase().indexOf(search.toLowerCase()) >= 0
|
||||
}
|
||||
}
|
||||
|
||||
class SubmitReasonFilter
|
||||
implements ClrDatagridStringFilterInterface<SubmitterData>
|
||||
{
|
||||
class SubmitReasonFilter implements ClrDatagridStringFilterInterface<SubmitterData> {
|
||||
accepts(data: SubmitterData, search: string): boolean {
|
||||
return data.submitReason.toLowerCase().indexOf(search.toLowerCase()) >= 0
|
||||
}
|
||||
@@ -40,7 +36,8 @@ class SubmitReasonFilter
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class SubmitterComponent implements OnInit, AfterViewInit {
|
||||
public remained: number = 0
|
||||
|
||||
@@ -13,7 +13,8 @@ import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapper
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class RoleComponent implements OnInit {
|
||||
public roles: Array<any> | undefined
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Component, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class HomeRouteComponent implements OnInit, OnDestroy {
|
||||
constructor() {}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Component, OnInit, OnDestroy } from '@angular/core'
|
||||
templateUrl: './multi-dataset-route.component.html',
|
||||
host: {
|
||||
class: 'content-container'
|
||||
}
|
||||
},
|
||||
standalone: false
|
||||
})
|
||||
export class MultiDatasetRouteComponent implements OnInit, OnDestroy {
|
||||
constructor() {}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class ReviewRouteComponent implements OnInit {
|
||||
constructor() {}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class UsernavRouteComponent implements OnInit {
|
||||
constructor() {}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Component, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class ViewRouteComponent implements OnInit, OnDestroy {
|
||||
constructor() {}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Component, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core'
|
||||
host: {
|
||||
class: 'content-container'
|
||||
},
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class XLMapRouteComponent implements OnInit, OnDestroy {
|
||||
constructor() {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import cloneDeep from 'lodash-es/cloneDeep'
|
||||
import * as CryptoMD5 from 'crypto-js/md5'
|
||||
import CryptoMD5 from 'crypto-js/md5'
|
||||
import { SasService } from './sas.service'
|
||||
|
||||
const librariesToShow = 50
|
||||
@@ -11,12 +11,8 @@ const librariesToShow = 50
|
||||
export class HelperService {
|
||||
public shownLibraries: number = librariesToShow
|
||||
public loadMoreCount: number = librariesToShow
|
||||
public isMicrosoft: boolean = false
|
||||
|
||||
constructor(private sasService: SasService) {
|
||||
this.isMicrosoft = this.isIEorEDGE()
|
||||
console.log('Is IE or Edge?', this.isMicrosoft)
|
||||
}
|
||||
constructor(private sasService: SasService) {}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
objectArray: Array<object>,
|
||||
deepClone: boolean = false
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { LicenseKeyData } from '../models/LicenseKeyData'
|
||||
import { SasService } from './sas.service'
|
||||
import * as moment from 'moment'
|
||||
import moment from 'moment'
|
||||
import * as base64Converter from 'base64-arraybuffer'
|
||||
import * as encoding from 'text-encoding'
|
||||
import { Router } from '@angular/router'
|
||||
|
||||
@@ -155,13 +155,23 @@ export class SasStoreService {
|
||||
.adapterResponse
|
||||
}
|
||||
|
||||
private libsPromise: Promise<any> | null = null
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns All libraries
|
||||
*/
|
||||
public async viewLibs() {
|
||||
return (await this.sasService.request('public/viewlibs', null))
|
||||
.adapterResponse
|
||||
public viewLibs() {
|
||||
if (!this.libsPromise) {
|
||||
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) {
|
||||
|
||||
@@ -120,9 +120,12 @@ export class SasViyaService {
|
||||
}
|
||||
|
||||
getComputeContexts(): Observable<ViyaComputeContexts> {
|
||||
return this.get<ViyaComputeContexts>(`${this.serverUrl}/compute/contexts`, {
|
||||
withCredentials: true
|
||||
})
|
||||
return this.get<ViyaComputeContexts>(
|
||||
`${this.serverUrl}/compute/contexts?limit=1000`,
|
||||
{
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
getComputeContextById(id: string): Observable<ComputeContextDetails> {
|
||||
|
||||
@@ -641,6 +641,23 @@ export class SasService {
|
||||
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() {
|
||||
return this.sasjsAdapter
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import { AbortDetails, InfoModal } from '../../models/InfoModal'
|
||||
selector: 'app-info-modal',
|
||||
templateUrl: './info-modal.component.html',
|
||||
styleUrls: ['./info-modal.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class InfoModalComponent implements OnInit {
|
||||
@Output() onConfirmModalClick: EventEmitter<any> = new EventEmitter()
|
||||
|
||||
@@ -7,7 +7,8 @@ import { AlertsService } from './alerts.service'
|
||||
selector: 'app-alerts',
|
||||
templateUrl: './alerts.component.html',
|
||||
styleUrls: ['./alerts.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class AlertsComponent implements OnInit {
|
||||
public alerts: Array<Alert> = []
|
||||
|
||||
@@ -19,7 +19,8 @@ export type OnLoadingMoreEvent = {
|
||||
selector: 'app-autocomplete',
|
||||
templateUrl: './autocomplete.component.html',
|
||||
styleUrls: ['./autocomplete.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class AutocompleteComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild('input') inputElement: any
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'
|
||||
selector: 'contact-link',
|
||||
templateUrl: './contact-link.component.html',
|
||||
styleUrls: ['./contact-link.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class ContactLinkComponent implements OnInit {
|
||||
@Input() classes: string = ''
|
||||
|
||||
@@ -15,7 +15,8 @@ import { Tab } from './models/dsmeta-groupped.model'
|
||||
selector: 'app-dataset-info',
|
||||
templateUrl: './dataset-info.component.html',
|
||||
styleUrls: ['./dataset-info.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class DatasetInfoComponent implements OnInit, OnChanges {
|
||||
@Input() open: boolean = false
|
||||
|
||||
@@ -22,7 +22,8 @@ import { TableClickEmitter } from './models/TableClickEmitter'
|
||||
selector: 'dc-tree',
|
||||
templateUrl: './dc-tree.component.html',
|
||||
styleUrls: ['./dc-tree.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: false
|
||||
})
|
||||
export class DcTreeComponent implements OnInit, AfterViewInit, OnChanges {
|
||||
// REFACTOR NOTICE
|
||||
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
} from './models/dc-validation.model'
|
||||
import { DQRule, DQRuleTypes } from './models/dq-rules.model'
|
||||
import { getDqDataCols } from './utils/getDqDataCols'
|
||||
import { getNotNullDefault } from './utils/getNotNullDefault'
|
||||
import { mergeColsRules } from './utils/mergeColsRules'
|
||||
import { parseColType } from './utils/parseColType'
|
||||
import { dqValidate } from './validations/dq-validation'
|
||||
import { specialMissingNumericValidator } from './validations/hot-custom-validators'
|
||||
import { applyNumericFormats } from './utils/applyNumericFormats'
|
||||
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
|
||||
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
|
||||
|
||||
export class DcValidator {
|
||||
private rules: DcValidation[] = []
|
||||
@@ -133,6 +135,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
|
||||
* The values comes from MPE_SELECTBOX table
|
||||
@@ -270,10 +304,18 @@ export class DcValidator {
|
||||
)
|
||||
|
||||
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].type = 'autocomplete'
|
||||
this.rules[i].editor = 'autocomplete.custom'
|
||||
this.rules[i].renderer = 'autocomplete'
|
||||
this.rules[i].filter = false
|
||||
|
||||
if (this.rules[i].sasType !== 'num') {
|
||||
this.rules[i].type = 'autocomplete'
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasDqRules(ruleColName, ['SOFTSELECT'])) {
|
||||
@@ -295,6 +337,30 @@ export class DcValidator {
|
||||
if (this.hasDqRules(ruleColName, ['NOTNULL'])) {
|
||||
this.rules[i].allowEmpty = false
|
||||
}
|
||||
|
||||
// READONLY: render column read-only (default value handled via getColumnDefault on add row)
|
||||
if (this.hasDqRules(ruleColName, ['READONLY'])) {
|
||||
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 numbro's numericFormat (set by applyNumericFormats on every
|
||||
// numeric column). On a numeric cell HOT lets numericFormat re-render
|
||||
// via numbro, which overrides our Intl renderer and drops the currency
|
||||
// symbol. The numeric editor/validator stay intact via `type`.
|
||||
this.rules[i].numericFormat = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Correct format comes as STRING from SAS. That could be also fixed on SAS side.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Handsontable from 'handsontable'
|
||||
import Core from 'handsontable/core'
|
||||
|
||||
export class CustomAutocompleteEditor extends Handsontable.editors
|
||||
.AutocompleteEditor {
|
||||
export class CustomAutocompleteEditor
|
||||
extends Handsontable.editors.AutocompleteEditor
|
||||
{
|
||||
constructor(instance: Core) {
|
||||
super(instance)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export interface DcColumnSettings {
|
||||
valid?: boolean
|
||||
desc?: 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 DcValidationRuleUpdate
|
||||
extends Handsontable.ColumnSettings,
|
||||
DcColumnSettings {
|
||||
extends Handsontable.ColumnSettings, DcColumnSettings {
|
||||
data?: string
|
||||
}
|
||||
|
||||
@@ -14,3 +14,7 @@ export type DQRuleTypes =
|
||||
| 'CASE'
|
||||
| 'MINVAL'
|
||||
| 'MAXVAL'
|
||||
| 'READONLY'
|
||||
| 'HIDDEN'
|
||||
| 'ROUND'
|
||||
| 'NUMBER_FORMAT'
|
||||
|
||||
@@ -235,6 +235,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 = [
|
||||
@@ -313,6 +344,30 @@ const example_dqRules: any = [
|
||||
RULE_TYPE: 'CASE',
|
||||
RULE_VALUE: 'LOWCASE',
|
||||
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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
describe('DC Validator - hot data schema', () => {
|
||||
@@ -8,4 +9,58 @@ describe('DC Validator - hot data schema', () => {
|
||||
).toEqual(1)
|
||||
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',
|
||||
desc: 'test_desc',
|
||||
clsRule: 'cls_rule',
|
||||
length: 8
|
||||
length: 8,
|
||||
sasType: 'test_type'
|
||||
}
|
||||
]
|
||||
|
||||
expect(mergeColsRules(cols, rules, $dataFormats)).toEqual(expected)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user