Browse Source

Merge pull request #3848 from nocodb/feat/make-cypress-test-suite-independent

Feat: Added and integrated playwright
pull/4182/head
Muhammed Mustafa 2 years ago committed by GitHub
parent
commit
0fd74d2986
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 95
      .github/workflows/ci-cd.yml
  2. 1
      .gitignore
  3. 4
      .husky/pre-commit
  4. 12
      .run/test-debug.run.xml
  5. 12
      .run/test.run.xml
  6. 129
      package-lock.json
  7. 15
      package.json
  8. 9
      packages/nc-gui/components/cell/MultiSelect.vue
  9. 9
      packages/nc-gui/components/cell/SingleSelect.vue
  10. 1
      packages/nc-gui/components/cell/attachment/index.vue
  11. 11
      packages/nc-gui/components/dashboard/TreeView.vue
  12. 10
      packages/nc-gui/components/dashboard/settings/AuditTab.vue
  13. 9
      packages/nc-gui/components/dashboard/settings/Modal.vue
  14. 1
      packages/nc-gui/components/dlg/TableCreate.vue
  15. 2
      packages/nc-gui/components/general/TruncateText.vue
  16. 4
      packages/nc-gui/components/smartsheet/Cell.vue
  17. 32
      packages/nc-gui/components/smartsheet/Form.vue
  18. 3
      packages/nc-gui/components/smartsheet/Gallery.vue
  19. 16
      packages/nc-gui/components/smartsheet/Grid.vue
  20. 2
      packages/nc-gui/components/smartsheet/Kanban.vue
  21. 2
      packages/nc-gui/components/smartsheet/Pagination.vue
  22. 2
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  23. 24
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  24. 1
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  25. 7
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  26. 2
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  27. 1
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  28. 9
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  29. 14
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  30. 5
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  31. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  32. 2
      packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue
  33. 2
      packages/nc-gui/components/webhook/Drawer.vue
  34. 2
      packages/nc-gui/layouts/base.vue
  35. 2
      packages/nc-gui/layouts/shared-view.vue
  36. 3
      packages/nc-gui/lib/constants.ts
  37. 1
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  38. 6
      packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue
  39. 9
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue
  40. 27
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue
  41. 12
      packages/nc-gui/pages/index/index/index.vue
  42. 9
      packages/nc-gui/pages/signin.vue
  43. 3
      packages/nocodb/package.json
  44. 4
      packages/nocodb/src/lib/meta/api/index.ts
  45. 36
      packages/nocodb/src/lib/meta/api/testApis.ts
  46. 55
      packages/nocodb/src/lib/models/User.ts
  47. 174
      packages/nocodb/src/lib/services/test/TestResetService/index.ts
  48. 151
      packages/nocodb/src/lib/services/test/TestResetService/resetMetaSakilaSqliteProject.ts
  49. 154
      packages/nocodb/src/lib/services/test/TestResetService/resetMysqlSakilaProject.ts
  50. 152
      packages/nocodb/src/lib/services/test/TestResetService/resetPgSakilaProject.ts
  51. 13
      packages/nocodb/src/lib/utils/common/NcConnectionMgrv2.ts
  52. 26
      packages/nocodb/src/lib/utils/globals.ts
  53. 42
      packages/nocodb/src/run/testDocker.ts
  54. 1710
      packages/nocodb/tests/pg-sakila-db/03-postgres-sakila-schema.sql
  55. 46702
      packages/nocodb/tests/pg-sakila-db/04-postgres-sakila-insert-data.sql
  56. 604
      packages/nocodb/tests/sqlite-sakila-db/03-sqlite-prefix-sakila-schema.sql
  57. 231486
      packages/nocodb/tests/sqlite-sakila-db/04-sqlite-prefix-sakila-insert-data.sql
  58. 1
      scripts/cypress/support/commands.js
  59. 1
      scripts/playwright/.env.example
  60. 70
      scripts/playwright/.eslintrc.json
  61. 8
      scripts/playwright/.gitignore
  62. 3
      scripts/playwright/.lintstagedrc.json
  63. 2
      scripts/playwright/.prettierignore
  64. 7
      scripts/playwright/.prettierrc.js
  65. 4
      scripts/playwright/constants/index.ts
  66. 110
      scripts/playwright/fixtures/expectedBaseDownloadData.txt
  67. 4
      scripts/playwright/fixtures/expectedData.txt
  68. 5
      scripts/playwright/fixtures/sampleFiles/1.json
  69. 4
      scripts/playwright/fixtures/sampleFiles/2.json
  70. 4
      scripts/playwright/fixtures/sampleFiles/3.json
  71. 4
      scripts/playwright/fixtures/sampleFiles/4.json
  72. 4
      scripts/playwright/fixtures/sampleFiles/5.json
  73. 4
      scripts/playwright/fixtures/sampleFiles/6.json
  74. BIN
      scripts/playwright/fixtures/sampleFiles/simple.xlsx
  75. 22
      scripts/playwright/fixtures/template.spec.ts
  76. 8336
      scripts/playwright/package-lock.json
  77. 41
      scripts/playwright/package.json
  78. 81
      scripts/playwright/pages/Base.ts
  79. 112
      scripts/playwright/pages/Dashboard/ExpandedForm/index.ts
  80. 252
      scripts/playwright/pages/Dashboard/Form/index.ts
  81. 32
      scripts/playwright/pages/Dashboard/Gallery/index.ts
  82. 56
      scripts/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts
  83. 49
      scripts/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  84. 78
      scripts/playwright/pages/Dashboard/Grid/Column/SelectOptionColumn.ts
  85. 219
      scripts/playwright/pages/Dashboard/Grid/Column/index.ts
  86. 280
      scripts/playwright/pages/Dashboard/Grid/index.ts
  87. 31
      scripts/playwright/pages/Dashboard/Import/Airtable.ts
  88. 79
      scripts/playwright/pages/Dashboard/Import/ImportTemplate.ts
  89. 140
      scripts/playwright/pages/Dashboard/Kanban/index.ts
  90. 29
      scripts/playwright/pages/Dashboard/Settings/Acl.ts
  91. 43
      scripts/playwright/pages/Dashboard/Settings/AppStore.ts
  92. 75
      scripts/playwright/pages/Dashboard/Settings/Audit.ts
  93. 15
      scripts/playwright/pages/Dashboard/Settings/Erd.ts
  94. 48
      scripts/playwright/pages/Dashboard/Settings/Metadata.ts
  95. 20
      scripts/playwright/pages/Dashboard/Settings/Miscellaneous.ts
  96. 99
      scripts/playwright/pages/Dashboard/Settings/Teams.ts
  97. 63
      scripts/playwright/pages/Dashboard/Settings/index.ts
  98. 86
      scripts/playwright/pages/Dashboard/SurveyForm/index.ts
  99. 136
      scripts/playwright/pages/Dashboard/TreeView.ts
  100. 132
      scripts/playwright/pages/Dashboard/ViewSidebar/index.ts
  101. Some files were not shown because too many files have changed in this diff Show More

95
.github/workflows/ci-cd.yml

@ -757,3 +757,98 @@ jobs:
name: cypress-restMisc-run-cache-snapshots name: cypress-restMisc-run-cache-snapshots
path: scripts/cypress/screenshots path: scripts/cypress/screenshots
retention-days: 2 retention-days: 2
playwright:
runs-on: ubuntu-20.04
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
# Reference: https://github.com/pierotofy/set-swap-space/blob/master/action.yml
- name: Set 5gb swap
shell: bash
# Delete the swap file, allocate a new one, and activate it
run: |
export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
sudo swapoff $SWAP_FILE
sudo rm $SWAP_FILE
sudo fallocate -l 5G $SWAP_FILE
sudo chmod 600 $SWAP_FILE
sudo mkswap $SWAP_FILE
sudo swapon $SWAP_FILE
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 16.15.0
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: install dependencies nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm install
- name: build nocodb-sdk
working-directory: ./packages/nocodb-sdk
run: npm run build
- name: Install dependencies
working-directory: ./packages/nocodb
run: npm install
- name: setup mysql
working-directory: ./
run: docker-compose -f ./scripts/playwright/scripts/docker-compose-playwright.yml up -d &
- name: run frontend
run: npm run start:web &
- name: Run backend
working-directory: ./packages/nocodb
run: npm run watch:run:playwright > mysql_test_backend.log &
- name: Cache playwright npm modules
uses: actions/cache@v3
id: playwright-cache
with:
path: |
**/playwright/node_modules
key: cache-playwright-${{ hashFiles('**/playwright/package-lock.json') }}
- name: Install dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: ./scripts/playwright
run: npm install
- name: Install Playwright Browsers
working-directory: ./scripts/playwright
run: npx playwright install chromium --with-deps
- name: Wait for frontend
run: |
while ! curl --output /dev/null --silent --head --fail http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png; do
printf '.'
sleep 2
done
- name: Wait for backend
run: |
while ! curl --output /dev/null --silent --head --fail http://localhost:8080; do
printf '.'
sleep 2
done
- name: Run Playwright tests
working-directory: ./scripts/playwright
run: npm run ci:test:mysql
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: ./scripts/playwright/playwright-report/
retention-days: 2
- uses: actions/upload-artifact@v3
if: always()
with:
name: backend logs
path: ./packages/nocodb/mysql_test_backend.log
retention-days: 2

1
.gitignore vendored

@ -93,3 +93,4 @@ shared.json
# NC_DBs # NC_DBs
#========= #=========
nc_minimal_dbs/ nc_minimal_dbs/
test_noco.db

4
.husky/pre-commit

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

12
.run/test-debug.run.xml

@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="test-debug" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/scripts/playwright/package.json" />
<command value="run" />
<scripts>
<script value="test-debug" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

12
.run/test.run.xml

@ -0,0 +1,12 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="test" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/scripts/playwright/package.json" />
<command value="run" />
<scripts>
<script value="test" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>

129
package-lock.json generated

@ -5082,6 +5082,12 @@
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
}, },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
},
"node_modules/ecc-jsbn": { "node_modules/ecc-jsbn": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@ -7282,6 +7288,21 @@
"ms": "^2.0.0" "ms": "^2.0.0"
} }
}, },
"node_modules/husky": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz",
"integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==",
"dev": true,
"bin": {
"husky": "lib/bin.js"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -8320,6 +8341,15 @@
"node": ">= 6.9.0" "node": ">= 6.9.0"
} }
}, },
"node_modules/lilconfig": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz",
"integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==",
"dev": true,
"engines": {
"node": ">=10"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -9700,6 +9730,15 @@
"semver": "bin/semver" "semver": "bin/semver"
} }
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-url": { "node_modules/normalize-url": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@ -10493,6 +10532,30 @@
"node": ">= 10.x" "node": ">= 10.x"
} }
}, },
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidtree": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true,
"bin": {
"pidtree": "bin/pidtree.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/pify": { "node_modules/pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
@ -12510,6 +12573,15 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/string-argv": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz",
"integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==",
"dev": true,
"engines": {
"node": ">=0.6.19"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -13638,6 +13710,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true "dev": true
}, },
"node_modules/yaml": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.3.tgz",
"integrity": "sha512-AacA8nRULjKMX2DvWvOAdBZMOfQlypSFkjcOcu9FalllIDJ1kvlREzcdIZmidQUqqeMv7jorHjq2HlLv/+c2lg==",
"dev": true,
"engines": {
"node": ">= 14"
}
},
"node_modules/yargs": { "node_modules/yargs": {
"version": "14.2.3", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz",
@ -17894,6 +17975,12 @@
} }
} }
}, },
"eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
},
"ecc-jsbn": { "ecc-jsbn": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@ -19693,6 +19780,12 @@
"ms": "^2.0.0" "ms": "^2.0.0"
} }
}, },
"husky": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/husky/-/husky-8.0.1.tgz",
"integrity": "sha512-xs7/chUH/CKdOCs7Zy0Aev9e/dKOMZf3K1Az1nar3tzlv0jfqnYtu235bstsWTmXOR0EfINrPa97yy4Lz6RiKw==",
"dev": true
},
"iconv-lite": { "iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -20475,6 +20568,12 @@
"npmlog": "^4.1.2" "npmlog": "^4.1.2"
} }
}, },
"lilconfig": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.5.tgz",
"integrity": "sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==",
"dev": true
},
"lines-and-columns": { "lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -21595,6 +21694,12 @@
} }
} }
}, },
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"normalize-url": { "normalize-url": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@ -22231,6 +22336,18 @@
} }
} }
}, },
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
},
"pidtree": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz",
"integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==",
"dev": true
},
"pify": { "pify": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
@ -23830,6 +23947,12 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"string-argv": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz",
"integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==",
"dev": true
},
"string-width": { "string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -24717,6 +24840,12 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true "dev": true
}, },
"yaml": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.1.3.tgz",
"integrity": "sha512-AacA8nRULjKMX2DvWvOAdBZMOfQlypSFkjcOcu9FalllIDJ1kvlREzcdIZmidQUqqeMv7jorHjq2HlLv/+c2lg==",
"dev": true
},
"yargs": { "yargs": {
"version": "14.2.3", "version": "14.2.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz",

15
package.json

@ -22,9 +22,21 @@
"cypress-iframe": "^1.0.1", "cypress-iframe": "^1.0.1",
"fs": "0.0.1-security", "fs": "0.0.1-security",
"lerna": "^3.20.1", "lerna": "^3.20.1",
"husky": "^8.0.0",
"xlsx": "^0.17.4" "xlsx": "^0.17.4"
}, },
"husky": {
"hooks": {
"pre-commit": "npx lint-staged"
}
},
"lint-staged": {
"scripts/playwright/**/*.{ts,tsx,js,json}": [
"npm run lint:staged:playwright"
]
},
"scripts": { "scripts": {
"lint:staged:playwright": "cd scripts/playwright; npx lint-staged; cd ..",
"build:common": "cd ./packages/nocodb-sdk; npm install; npm run build", "build:common": "cd ./packages/nocodb-sdk; npm install; npm run build",
"install:common": "cd ./packages/nocodb; npm install ../nocodb-sdk; cd ../nc-gui; npm install ../nocodb-sdk", "install:common": "cd ./packages/nocodb; npm install ../nocodb-sdk; cd ../nc-gui; npm install ../nocodb-sdk",
"start:api": "npm run build:common ; cd ./packages/nocodb; npm install ../nocodb-sdk; npm install; NC_DISABLE_CACHE=true NC_DISABLE_TELE=true npm run watch:run:cypress", "start:api": "npm run build:common ; cd ./packages/nocodb; npm install ../nocodb-sdk; npm install; NC_DISABLE_CACHE=true NC_DISABLE_TELE=true npm run watch:run:cypress",
@ -44,7 +56,8 @@
"install:local:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib;rm package-lock.json; npm i ../../../xc-lib-private; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i ../../../xc-lib-private;npm i ../xc-lib-gui", "install:local:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib;rm package-lock.json; npm i ../../../xc-lib-private; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i ../../../xc-lib-private;npm i ../xc-lib-gui",
"install:npm:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib; npm i -S xc-lib@latest; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i -S xc-lib@latest xc-lib-gui@latest;npm i ../xc-lib-gui", "install:npm:dep": "cd packages/nc-lib-gui;npm uninstall -S xc-lib; npm i -S xc-lib@latest; cd ../xc-instant;npm uninstall -S xc-lib xc-lib-gui;npm i -S xc-lib@latest xc-lib-gui@latest;npm i ../xc-lib-gui",
"start:pg": "docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d", "start:pg": "docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d",
"stop:pg": "docker-compose -f ./scripts/cypress/docker-compose-pg.yml down" "stop:pg": "docker-compose -f ./scripts/cypress/docker-compose-pg.yml down",
"prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.1", "express": "^4.18.1",

9
packages/nc-gui/components/cell/MultiSelect.vue

@ -20,6 +20,7 @@ import MdiCloseCircle from '~icons/mdi/close-circle'
interface Props { interface Props {
modelValue?: string | string[] modelValue?: string | string[]
rowIndex?: number
} }
const { modelValue } = defineProps<Props>() const { modelValue } = defineProps<Props>()
@ -150,7 +151,13 @@ watch(isOpen, (n, _o) => {
@keydown="handleKeys" @keydown="handleKeys"
@click="isOpen = !isOpen" @click="isOpen = !isOpen"
> >
<a-select-option v-for="op of options" :key="op.id" :value="op.title" @click.stop> <a-select-option
v-for="op of options"
:key="op.id"
:value="op.title"
:data-nc="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color"> <a-tag class="rounded-tag" :color="op.color">
<span <span
:style="{ :style="{

9
packages/nc-gui/components/cell/SingleSelect.vue

@ -6,6 +6,7 @@ import { ActiveCellInj, ColumnInj, IsKanbanInj, ReadonlyInj, computed, inject, r
interface Props { interface Props {
modelValue?: string | undefined modelValue?: string | undefined
rowIndex?: number
} }
const { modelValue } = defineProps<Props>() const { modelValue } = defineProps<Props>()
@ -81,7 +82,13 @@ watch(isOpen, (n, _o) => {
@keydown="handleKeys" @keydown="handleKeys"
@click="isOpen = !isOpen" @click="isOpen = !isOpen"
> >
<a-select-option v-for="op of options" :key="op.title" :value="op.title" @click.stop> <a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-nc="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color"> <a-tag class="rounded-tag" :color="op.color">
<span <span
:style="{ :style="{

1
packages/nc-gui/components/cell/attachment/index.vue

@ -160,6 +160,7 @@ watch(
v-if="!isReadonly" v-if="!isReadonly"
:class="{ 'mx-auto px-4': !visibleItems.length }" :class="{ 'mx-auto px-4': !visibleItems.length }"
class="group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)" class="group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
data-nc="attachment-cell-file-picker-button"
@click.stop="open" @click.stop="open"
> >
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> <MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />

11
packages/nc-gui/components/dashboard/TreeView.vue

@ -321,12 +321,13 @@ function openTableCreateDialog() {
class="nc-tree-item text-sm cursor-pointer group" class="nc-tree-item text-sm cursor-pointer group"
:data-order="table.order" :data-order="table.order"
:data-id="table.id" :data-id="table.id"
:data-nc="`tree-view-table-${table.title}`"
@click="addTableTab(table)" @click="addTableTab(table)"
> >
<GeneralTooltip class="pl-5 pr-3 py-2" modifier-key="Alt"> <GeneralTooltip class="pl-5 pr-3 py-2" modifier-key="Alt">
<template #title>{{ table.table_name }}</template> <template #title>{{ table.table_name }}</template>
<div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)"> <div class="flex items-center gap-2 h-full" @contextmenu="setMenuContext('table', table)">
<div class="flex w-auto"> <div class="flex w-auto" :data-nc="`tree-view-table-draggable-handle-${table.title}`">
<MdiDragVertical <MdiDragVertical
v-if="isUIAllowed('treeview-drag-n-drop')" v-if="isUIAllowed('treeview-drag-n-drop')"
:class="`nc-child-draggable-icon-${table.title}`" :class="`nc-child-draggable-icon-${table.title}`"
@ -355,12 +356,16 @@ function openTableCreateDialog() {
<template #overlay> <template #overlay>
<a-menu class="!py-0 rounded text-sm"> <a-menu class="!py-0 rounded text-sm">
<a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table)"> <a-menu-item v-if="isUIAllowed('table-rename')" @click="openRenameTableDialog(table)">
<div class="nc-project-menu-item"> <div class="nc-project-menu-item" :data-nc="`sidebar-table-rename-${table.title}`">
{{ $t('general.rename') }} {{ $t('general.rename') }}
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item v-if="isUIAllowed('table-delete')" @click="deleteTable(table)"> <a-menu-item
v-if="isUIAllowed('table-delete')"
:data-nc="`sidebar-table-delete-${table.title}`"
@click="deleteTable(table)"
>
<div class="nc-project-menu-item"> <div class="nc-project-menu-item">
{{ $t('general.delete') }} {{ $t('general.delete') }}
</div> </div>

10
packages/nc-gui/components/dashboard/settings/AuditTab.vue

@ -105,7 +105,15 @@ const columns = [
/> />
</div> </div>
<a-table class="w-full" size="small" :data-source="audits ?? []" :columns="columns" :pagination="false" :loading="isLoading"> <a-table
class="w-full"
size="small"
:data-source="audits ?? []"
:columns="columns"
:pagination="false"
:loading="isLoading"
data-nc="audit-tab-table"
>
<template #emptyText> <template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template> </template>

9
packages/nc-gui/components/dashboard/settings/Modal.vue

@ -175,7 +175,12 @@ watch(
{{ $t('activity.settings') }} {{ $t('activity.settings') }}
</a-typography-title> </a-typography-title>
<a-button type="text" class="!rounded-md border-none -mt-1.5 -mr-1" @click="vModel = false"> <a-button
type="text"
class="!rounded-md border-none -mt-1.5 -mr-1"
data-nc="settings-modal-close-button"
@click="vModel = false"
>
<template #icon> <template #icon>
<MdiClose class="cursor-pointer mt-1 nc-modal-close" /> <MdiClose class="cursor-pointer mt-1 nc-modal-close" />
</template> </template>
@ -215,7 +220,7 @@ watch(
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
<component :is="selectedSubTab?.body" class="px-2 py-6" /> <component :is="selectedSubTab?.body" class="px-2 py-6" :data-nc="`nc-settings-subtab-${selectedSubTab.title}`" />
</a-layout-content> </a-layout-content>
</a-layout> </a-layout>
</a-modal> </a-modal>

1
packages/nc-gui/components/dlg/TableCreate.vue

@ -105,6 +105,7 @@ onMounted(() => {
v-model:value="table.title" v-model:value="table.title"
size="large" size="large"
hide-details hide-details
data-nc="create-table-title-input"
:placeholder="$t('msg.info.enterTableName')" :placeholder="$t('msg.info.enterTableName')"
/> />
</a-form-item> </a-form-item>

2
packages/nc-gui/components/general/TruncateText.vue

@ -38,7 +38,7 @@ const shortName = computed(() =>
</template> </template>
<div class="w-full">{{ shortName }}</div> <div class="w-full">{{ shortName }}</div>
</a-tooltip> </a-tooltip>
<div v-else class="w-full"> <div v-else class="w-full" data-nc="truncate-label">
<slot /> <slot />
</div> </div>
<div ref="text" class="hidden"> <div ref="text" class="hidden">

4
packages/nc-gui/components/smartsheet/Cell.vue

@ -132,8 +132,8 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
<LazyCellTextArea v-if="isTextArea" v-model="vModel" /> <LazyCellTextArea v-if="isTextArea" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean" v-model="vModel" /> <LazyCellCheckbox v-else-if="isBoolean" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment" v-model="vModel" :row-index="props.rowIndex" /> <LazyCellAttachment v-else-if="isAttachment" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect v-else-if="isSingleSelect" v-model="vModel" /> <LazyCellSingleSelect v-else-if="isSingleSelect" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellMultiSelect v-else-if="isMultiSelect" v-model="vModel" /> <LazyCellMultiSelect v-else-if="isMultiSelect" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDatePicker v-else-if="isDate" v-model="vModel" /> <LazyCellDatePicker v-else-if="isDate" v-model="vModel" />
<LazyCellYearPicker v-else-if="isYear" v-model="vModel" /> <LazyCellYearPicker v-else-if="isYear" v-model="vModel" />
<LazyCellDateTimePicker v-else-if="isDateTime" v-model="vModel" /> <LazyCellDateTimePicker v-else-if="isDateTime" v-model="vModel" />

32
packages/nc-gui/components/smartsheet/Form.vue

@ -388,7 +388,7 @@ watch(view, (nextView) => {
</script> </script>
<template> <template>
<a-row v-if="submitted" class="h-full"> <a-row v-if="submitted" class="h-full" data-nc="nc-form-wrapper-submit">
<a-col :span="24"> <a-col :span="24">
<div v-if="formViewData" class="items-center justify-center text-center mt-2"> <div v-if="formViewData" class="items-center justify-center text-center mt-2">
<a-alert type="success"> <a-alert type="success">
@ -408,7 +408,7 @@ watch(view, (nextView) => {
</a-col> </a-col>
</a-row> </a-row>
<a-row v-else class="h-full flex"> <a-row v-else class="h-full flex" data-nc="nc-form-wrapper">
<a-col v-if="isEditable" :span="8" class="shadow p-2 md:p-4 h-full overflow-auto scrollbar-thin-dull nc-form-left-drawer"> <a-col v-if="isEditable" :span="8" class="shadow p-2 md:p-4 h-full overflow-auto scrollbar-thin-dull nc-form-left-drawer">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div class="flex-1 text-lg"> <div class="flex-1 text-lg">
@ -420,6 +420,7 @@ watch(view, (nextView) => {
v-if="hiddenColumns.length" v-if="hiddenColumns.length"
type="button" type="button"
class="nc-form-add-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded" class="nc-form-add-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
data-nc="nc-form-add-all"
@click="addAllColumns" @click="addAllColumns"
> >
<!-- Add all --> <!-- Add all -->
@ -430,6 +431,7 @@ watch(view, (nextView) => {
v-if="localColumns.length" v-if="localColumns.length"
type="button" type="button"
class="nc-form-remove-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded" class="nc-form-remove-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
data-nc="nc-form-remove-all"
@click="removeAllColumns" @click="removeAllColumns"
> >
<!-- Remove all --> <!-- Remove all -->
@ -451,6 +453,7 @@ watch(view, (nextView) => {
<a-card <a-card
size="small" size="small"
class="!border-0 color-transition cursor-pointer item hover:(bg-primary ring-1 ring-accent ring-opacity-100) bg-opacity-10 !rounded !shadow-lg" class="!border-0 color-transition cursor-pointer item hover:(bg-primary ring-1 ring-accent ring-opacity-100) bg-opacity-10 !rounded !shadow-lg"
:data-nc="`nc-form-hidden-column-${element.label || element.title}`"
@mousedown="moved = false" @mousedown="moved = false"
@mousemove="moved = false" @mousemove="moved = false"
@mouseup="handleMouseUp(element, index)" @mouseup="handleMouseUp(element, index)"
@ -478,6 +481,7 @@ watch(view, (nextView) => {
<template #footer> <template #footer>
<div <div
class="my-4 select-none border-dashed border-2 border-gray-400 py-3 text-gray-400 text-center nc-drag-n-drop-to-hide" class="my-4 select-none border-dashed border-2 border-gray-400 py-3 text-gray-400 text-center nc-drag-n-drop-to-hide"
data-nc="nc-drag-n-drop-to-hide"
> >
<!-- Drag and drop fields here to hide --> <!-- Drag and drop fields here to hide -->
{{ $t('msg.info.dragDropHide') }} {{ $t('msg.info.dragDropHide') }}
@ -535,6 +539,7 @@ watch(view, (nextView) => {
hide-details hide-details
placeholder="Form Title" placeholder="Form Title"
:bordered="false" :bordered="false"
data-nc="nc-form-heading"
@blur="updateView" @blur="updateView"
@keydown.enter="updateView" @keydown.enter="updateView"
/> />
@ -554,6 +559,7 @@ watch(view, (nextView) => {
:placeholder="$t('msg.info.formDesc')" :placeholder="$t('msg.info.formDesc')"
:bordered="false" :bordered="false"
:disabled="!isEditable" :disabled="!isEditable"
data-nc="nc-form-sub-heading"
@blur="updateView" @blur="updateView"
@click="updateView" @click="updateView"
/> />
@ -583,19 +589,25 @@ watch(view, (nextView) => {
'bg-primary bg-opacity-5 ring-0.5 ring-accent ring-opacity-100': activeRow === element.title, 'bg-primary bg-opacity-5 ring-0.5 ring-accent ring-opacity-100': activeRow === element.title,
}, },
]" ]"
data-nc="nc-form-fields"
@click="activeRow = element.title" @click="activeRow = element.title"
> >
<div <div
v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)" v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)"
class="absolute flex top-2 right-2" class="absolute flex top-2 right-2"
> >
<MdiEyeOffOutline class="opacity-0 nc-field-remove-icon" @click.stop="hideColumn(index)" /> <MdiEyeOffOutline
class="opacity-0 nc-field-remove-icon"
data-nc="nc-field-remove-icon"
@click.stop="hideColumn(index)"
/>
</div> </div>
<div v-if="activeRow === element.title" class="flex flex-col gap-3 mb-3"> <div v-if="activeRow === element.title" class="flex flex-col gap-3 mb-3">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<span <span
class="text-gray-500 mr-2 nc-form-input-required" class="text-gray-500 mr-2 nc-form-input-required"
data-nc="nc-form-input-required"
@click=" @click="
() => { () => {
element.required = !element.required element.required = !element.required
@ -619,6 +631,7 @@ watch(view, (nextView) => {
v-model:value="element.label" v-model:value="element.label"
type="text" type="text"
class="form-meta-input nc-form-input-label" class="form-meta-input nc-form-input-label"
data-nc="nc-form-input-label"
:placeholder="$t('msg.info.formInput')" :placeholder="$t('msg.info.formInput')"
@change="updateColMeta(element)" @change="updateColMeta(element)"
> >
@ -630,6 +643,7 @@ watch(view, (nextView) => {
v-model:value="element.description" v-model:value="element.description"
type="text" type="text"
class="form-meta-input text-sm nc-form-input-help-text" class="form-meta-input text-sm nc-form-input-help-text"
data-nc="nc-form-input-help-text"
:placeholder="$t('msg.info.formHelpText')" :placeholder="$t('msg.info.formHelpText')"
@change="updateColMeta(element)" @change="updateColMeta(element)"
/> />
@ -642,6 +656,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }" :column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)" :required="isRequired(element, element.required)"
:hide-menu="true" :hide-menu="true"
data-nc="nc-form-input-label"
/> />
<LazySmartsheetHeaderCell <LazySmartsheetHeaderCell
@ -649,6 +664,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }" :column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)" :required="isRequired(element, element.required)"
:hide-menu="true" :hide-menu="true"
data-nc="nc-form-input-label"
/> />
</div> </div>
@ -663,6 +679,7 @@ watch(view, (nextView) => {
:row="row" :row="row"
class="nc-input" class="nc-input"
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`" :class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-nc="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element" :column="element"
@click.stop.prevent @click.stop.prevent
/> />
@ -678,13 +695,14 @@ watch(view, (nextView) => {
v-model="formState[element.title]" v-model="formState[element.title]"
class="nc-input" class="nc-input"
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`" :class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-nc="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element" :column="element"
:edit-enabled="true" :edit-enabled="true"
@click.stop.prevent @click.stop.prevent
/> />
</a-form-item> </a-form-item>
<div class="text-gray-500 text-xs">{{ element.description }}</div> <div class="text-gray-500 text-xs" data-nc="nc-form-input-help-text-label">{{ element.description }}</div>
</div> </div>
</template> </template>
@ -699,7 +717,7 @@ watch(view, (nextView) => {
</Draggable> </Draggable>
<div class="justify-center flex mt-6"> <div class="justify-center flex mt-6">
<button type="submit" class="uppercase scaling-btn nc-form-submit" @click="submitForm"> <button type="submit" class="uppercase scaling-btn nc-form-submit" data-nc="nc-form-submit" @click="submitForm">
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
</div> </div>
@ -721,6 +739,7 @@ watch(view, (nextView) => {
:rows="3" :rows="3"
hide-details hide-details
class="nc-form-after-submit-msg" class="nc-form-after-submit-msg"
data-nc="nc-form-after-submit-msg"
@change="updateView" @change="updateView"
/> />
@ -733,6 +752,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:submit-another-form`]" v-e="[`a:form-view:submit-another-form`]"
size="small" size="small"
class="nc-form-checkbox-submit-another-form" class="nc-form-checkbox-submit-another-form"
data-nc="nc-form-checkbox-submit-another-form"
@change="updateView" @change="updateView"
/> />
<span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span> <span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span>
@ -745,6 +765,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:show-blank-form`]" v-e="[`a:form-view:show-blank-form`]"
size="small" size="small"
class="nc-form-checkbox-show-blank-form" class="nc-form-checkbox-show-blank-form"
data-nc="nc-form-checkbox-show-blank-form"
@change="updateView" @change="updateView"
/> />
@ -757,6 +778,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:email-me`]" v-e="[`a:form-view:email-me`]"
size="small" size="small"
class="nc-form-checkbox-send-email" class="nc-form-checkbox-send-email"
data-nc="nc-form-checkbox-send-email"
@change="onEmailChange" @change="onEmailChange"
/> />

3
packages/nc-gui/components/smartsheet/Gallery.vue

@ -163,13 +163,14 @@ watch(view, async (nextView) => {
</script> </script>
<template> <template>
<div class="flex flex-col h-full w-full overflow-auto nc-gallery"> <div class="flex flex-col h-full w-full overflow-auto nc-gallery" data-nc="nc-gallery-wrapper">
<div class="nc-gallery-container grid gap-2 my-4 px-3"> <div class="nc-gallery-container grid gap-2 my-4 px-3">
<div v-for="record in data" :key="`record-${record.row.id}`"> <div v-for="record in data" :key="`record-${record.row.id}`">
<LazySmartsheetRow :row="record"> <LazySmartsheetRow :row="record">
<a-card <a-card
hoverable hoverable
class="!rounded-lg h-full overflow-hidden break-all max-w-[450px]" class="!rounded-lg h-full overflow-hidden break-all max-w-[450px]"
:data-nc="`nc-gallery-card-${record.row.id}`"
@click="expandFormClick($event, record)" @click="expandFormClick($event, record)"
> >
<template v-if="galleryData?.fk_cover_image_col_id" #cover> <template v-if="galleryData?.fk_cover_image_col_id" #cover>

16
packages/nc-gui/components/smartsheet/Grid.vue

@ -460,8 +460,8 @@ watch(
</script> </script>
<template> <template>
<div class="relative flex flex-col h-full min-h-0 w-full"> <div class="relative flex flex-col h-full min-h-0 w-full" data-nc="nc-grid-wrapper">
<general-overlay :model-value="isLoading" inline transition class="!bg-opacity-15"> <general-overlay :model-value="isLoading" inline transition class="!bg-opacity-15" data-nc="grid-load-spinner">
<div class="flex items-center justify-center h-full w-full !bg-white !bg-opacity-85 z-1000"> <div class="flex items-center justify-center h-full w-full !bg-white !bg-opacity-85 z-1000">
<a-spin size="large" /> <a-spin size="large" />
</div> </div>
@ -480,8 +480,8 @@ watch(
> >
<thead ref="tableHead"> <thead ref="tableHead">
<tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]"> <tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]">
<th> <th data-nc="grid-id-column">
<div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center"> <div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center" data-nc="nc-check-all">
<template v-if="!readOnly"> <template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div> <div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div>
<div <div
@ -546,8 +546,8 @@ watch(
<tbody ref="tbodyEl" @selectstart.prevent> <tbody ref="tbodyEl" @selectstart.prevent>
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row"> <LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }"> <template #default="{ state }">
<tr class="nc-grid-row"> <tr class="nc-grid-row" :data-nc="`grid-row-${rowIndex}`">
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1"> <td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-nc="`cell-Id-${rowIndex}`">
<div class="items-center flex gap-1 min-w-[55px]"> <div class="items-center flex gap-1 min-w-[55px]">
<div <div
v-if="!readOnly || !isLocked" v-if="!readOnly || !isLocked"
@ -568,9 +568,10 @@ watch(
<div <div
v-if="!readOnly || hasRole('commenter', true) || hasRole('viewer', true)" v-if="!readOnly || hasRole('commenter', true) || hasRole('viewer', true)"
class="nc-expand" class="nc-expand"
:data-nc="`nc-expand-${rowIndex}`"
:class="{ 'nc-comment': row.rowMeta?.commentCount }" :class="{ 'nc-comment': row.rowMeta?.commentCount }"
> >
<a-spin v-if="row.rowMeta.saving" class="!flex items-center" /> <a-spin v-if="row.rowMeta.saving" class="!flex items-center" :data-nc="`row-save-spinner-${rowIndex}`" />
<template v-else> <template v-else>
<span <span
v-if="row.rowMeta?.commentCount" v-if="row.rowMeta?.commentCount"
@ -604,6 +605,7 @@ watch(
(hasEditPermission && selectedRange(rowIndex, colIndex)), (hasEditPermission && selectedRange(rowIndex, colIndex)),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row), 'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
}" }"
:data-nc="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id" :data-key="rowIndex + columnObj.id"
:data-col="columnObj.id" :data-col="columnObj.id"
:data-title="columnObj.title" :data-title="columnObj.title"

2
packages/nc-gui/components/smartsheet/Kanban.vue

@ -309,7 +309,7 @@ watch(view, async (nextView) => {
</script> </script>
<template> <template>
<div class="flex h-full bg-white px-2"> <div class="flex h-full bg-white px-2" data-nc="nc-kanban-wrapper">
<div ref="kanbanContainerRef" class="nc-kanban-container flex my-4 px-3 overflow-x-scroll overflow-y-hidden"> <div ref="kanbanContainerRef" class="nc-kanban-container flex my-4 px-3 overflow-x-scroll overflow-y-hidden">
<a-dropdown v-model:visible="contextMenu" :trigger="['contextmenu']" overlay-class-name="nc-dropdown-kanban-context-menu"> <a-dropdown v-model:visible="contextMenu" :trigger="['contextmenu']" overlay-class-name="nc-dropdown-kanban-context-menu">
<!-- Draggable Stack --> <!-- Draggable Stack -->

2
packages/nc-gui/components/smartsheet/Pagination.vue

@ -19,7 +19,7 @@ const page = computed({
<template> <template>
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
<span v-if="count !== null && count !== Infinity" class="caption ml-5 text-gray-500"> <span v-if="count !== null && count !== Infinity" class="caption ml-5 text-gray-500" data-nc="grid-pagination">
{{ count }} {{ count !== 1 ? $t('objects.records') : $t('objects.record') }} {{ count }} {{ count !== 1 ? $t('objects.records') : $t('objects.record') }}
</span> </span>

2
packages/nc-gui/components/smartsheet/column/EditOrAdd.vue

@ -124,7 +124,7 @@ onMounted(() => {
:class="{ '!w-[600px]': formState.uidt === UITypes.Formula }" :class="{ '!w-[600px]': formState.uidt === UITypes.Formula }"
@click.stop @click.stop
> >
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical"> <a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-nc="add-or-edit-column">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title"> <a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title">
<a-input <a-input

24
packages/nc-gui/components/smartsheet/column/SelectOptions.vue

@ -133,7 +133,12 @@ watch(inputs, () => {
<Draggable :list="options" item-key="id" handle=".nc-child-draggable-icon"> <Draggable :list="options" item-key="id" handle=".nc-child-draggable-icon">
<template #item="{ element, index }"> <template #item="{ element, index }">
<div class="flex py-1 items-center nc-select-option"> <div class="flex py-1 items-center nc-select-option">
<MdiDragVertical v-if="!isKanban" small class="nc-child-draggable-icon handle" /> <MdiDragVertical
v-if="!isKanban"
small
class="nc-child-draggable-icon handle"
:data-nc="`select-option-column-handle-icon-${element.title}`"
/>
<a-dropdown <a-dropdown
v-model:visible="colorMenus[index]" v-model:visible="colorMenus[index]"
:trigger="['click']" :trigger="['click']"
@ -153,9 +158,20 @@ watch(inputs, () => {
/> />
</a-dropdown> </a-dropdown>
<a-input ref="inputs" v-model:value="element.title" class="caption" @change="optionChanged(element.id)" /> <a-input
ref="inputs"
<MdiClose class="ml-2 hover:!text-black" :style="{ color: 'red' }" @click="removeOption(index)" /> v-model:value="element.title"
class="caption"
:data-nc="`select-column-option-input-${index}`"
@change="optionChanged(element.id)"
/>
<MdiClose
class="ml-2 hover:!text-black"
:style="{ color: 'red' }"
:data-nc="`select-column-option-remove-${index}`"
@click="removeOption(index)"
/>
</div> </div>
</template> </template>
<template #footer> <template #footer>

1
packages/nc-gui/components/smartsheet/expanded-form/index.vue

@ -150,6 +150,7 @@ export default {
:key="col.title" :key="col.title"
class="mt-2 py-2" class="mt-2 py-2"
:class="`nc-expand-col-${col.title}`" :class="`nc-expand-col-${col.title}`"
:data-nc="`nc-expand-col-${col.title}`"
> >
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" /> <LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />

7
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue

@ -167,11 +167,12 @@ function onStopEdit() {
<template> <template>
<a-menu-item <a-menu-item
class="select-none group !flex !items-center !my-0 hover:(bg-primary !bg-opacity-5)" class="select-none group !flex !items-center !my-0 hover:(bg-primary !bg-opacity-5)"
:data-nc="`view-sidebar-view-${vModel.alias || vModel.title}`"
@dblclick.stop="onDblClick" @dblclick.stop="onDblClick"
@click.stop="onClick" @click.stop="onClick"
> >
<div v-e="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2"> <div v-e="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-2" data-nc="view-item">
<div class="flex w-auto"> <div class="flex w-auto" :data-nc="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<MdiDrag <MdiDrag
class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 !cursor-move" class="nc-drag-icon hidden group-hover:block transition-opacity opacity-0 group-hover:opacity-100 text-gray-500 !cursor-move"
@click.stop.prevent @click.stop.prevent
@ -193,7 +194,7 @@ function onStopEdit() {
<div class="flex-1" /> <div class="flex-1" />
<template v-if="!isEditing && !isLocked && isUIAllowed('virtualViewsCreateOrEdit')"> <template v-if="!isEditing && !isLocked && isUIAllowed('virtualViewsCreateOrEdit')">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1" :data-nc="`view-sidebar-view-actions-${vModel.alias || vModel.title}`">
<a-tooltip placement="left"> <a-tooltip placement="left">
<template #title> <template #title>
{{ $t('activity.copyView') }} {{ $t('activity.copyView') }}

2
packages/nc-gui/components/smartsheet/sidebar/index.vue

@ -121,7 +121,7 @@ function onOpenModal({
collapsiple collapsiple
collapsed-width="0" collapsed-width="0"
width="0" width="0"
class="relative shadow h-full w-full !flex-1 !min-w-0 !max-w-[150px] !w-[150px] lg:(!max-w-[250px] !w-[250px])" class="nc-view-sidebar relative shadow h-full w-full !flex-1 !min-w-0 !max-w-[150px] !w-[150px] lg:(!max-w-[250px] !w-[250px])"
theme="light" theme="light"
> >
<LazySmartsheetSidebarToolbar <LazySmartsheetSidebarToolbar

1
packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue

@ -82,6 +82,7 @@ const filterAutoSaveLoc = computed({
ref="filterComp" ref="filterComp"
class="nc-table-toolbar-menu shadow-lg" class="nc-table-toolbar-menu shadow-lg"
:auto-save="filterAutoSave" :auto-save="filterAutoSave"
data-nc="nc-filter-menu"
@update:filters-length="filtersLength = $event" @update:filters-length="filtersLength = $event"
> >
<div v-if="!isPublic" class="flex items-end mt-2 min-h-[30px]" @click.stop> <div v-if="!isPublic" class="flex items-end mt-2 min-h-[30px]" @click.stop>

9
packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue

@ -139,6 +139,7 @@ const getIcon = (c: ColumnType) =>
<template #overlay> <template #overlay>
<div <div
class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border" class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border"
data-nc="nc-fields-menu"
@click.stop @click.stop
> >
<a-card <a-card
@ -162,7 +163,13 @@ const getIcon = (c: ColumnType) =>
<div class="nc-fields-list py-1"> <div class="nc-fields-list py-1">
<Draggable v-model="fields" item-key="id" @change="onMove($event)"> <Draggable v-model="fields" item-key="id" @change="onMove($event)">
<template #item="{ element: field, index: index }"> <template #item="{ element: field, index: index }">
<div v-show="filteredFieldList.includes(field)" :key="field.id" class="px-2 py-1 flex items-center" @click.stop> <div
v-show="filteredFieldList.includes(field)"
:key="field.id"
class="px-2 py-1 flex items-center"
:data-nc="`nc-fields-menu-${field.title}`"
@click.stop
>
<a-checkbox <a-checkbox
v-model:checked="field.show" v-model:checked="field.show"
v-e="['a:fields:show-hide']" v-e="['a:fields:show-hide']"

14
packages/nc-gui/components/smartsheet/toolbar/ShareView.vue

@ -213,6 +213,7 @@ watch(passwordProtected, (value) => {
> >
<div <div
data-cy="nc-modal-share-view__link" data-cy="nc-modal-share-view__link"
data-nc="nc-modal-share-view__link"
class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100" class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100"
> >
<div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div> <div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div>
@ -235,6 +236,7 @@ watch(passwordProtected, (value) => {
v-if="shared.type === ViewTypes.FORM" v-if="shared.type === ViewTypes.FORM"
v-model:checked="surveyMode" v-model:checked="surveyMode"
data-cy="nc-modal-share-view__survey-mode" data-cy="nc-modal-share-view__survey-mode"
data-nc="nc-modal-share-view__survey-mode"
class="!text-sm" class="!text-sm"
> >
Use Survey Mode Use Survey Mode
@ -265,6 +267,7 @@ watch(passwordProtected, (value) => {
v-if="shared.type === ViewTypes.FORM" v-if="shared.type === ViewTypes.FORM"
v-model:checked="viewTheme" v-model:checked="viewTheme"
data-cy="nc-modal-share-view__with-theme" data-cy="nc-modal-share-view__with-theme"
data-nc="nc-modal-share-view__with-theme"
class="!text-sm" class="!text-sm"
> >
Use Theme Use Theme
@ -279,6 +282,7 @@ watch(passwordProtected, (value) => {
:colors="projectThemeColors" :colors="projectThemeColors"
:row-size="9" :row-size="9"
:advanced="false" :advanced="false"
data-nc="nc-modal-share-view__theme-picker"
@input="onChangeTheme" @input="onChangeTheme"
/> />
</div> </div>
@ -287,7 +291,12 @@ watch(passwordProtected, (value) => {
<div> <div>
<!-- Password Protection --> <!-- Password Protection -->
<a-checkbox v-model:checked="passwordProtected" data-cy="nc-modal-share-view__with-password" class="!text-sm !my-1"> <a-checkbox
v-model:checked="passwordProtected"
data-cy="nc-modal-share-view__with-password"
class="!text-sm !my-1"
data-nc="nc-modal-share-view__with-password"
>
{{ $t('msg.info.beforeEnablePwd') }} {{ $t('msg.info.beforeEnablePwd') }}
</a-checkbox> </a-checkbox>
@ -296,6 +305,7 @@ watch(passwordProtected, (value) => {
<a-input <a-input
v-model:value="shared.password" v-model:value="shared.password"
data-cy="nc-modal-share-view__password" data-cy="nc-modal-share-view__password"
data-nc="nc-modal-share-view__password"
size="small" size="small"
class="!text-xs max-w-[250px]" class="!text-xs max-w-[250px]"
type="password" type="password"
@ -304,6 +314,7 @@ watch(passwordProtected, (value) => {
<a-button <a-button
data-cy="nc-modal-share-view__save-password" data-cy="nc-modal-share-view__save-password"
data-nc="nc-modal-share-view__save-password"
size="small" size="small"
class="!text-xs" class="!text-xs"
@click="saveShareLinkPassword" @click="saveShareLinkPassword"
@ -323,6 +334,7 @@ watch(passwordProtected, (value) => {
" "
v-model:checked="allowCSVDownload" v-model:checked="allowCSVDownload"
data-cy="nc-modal-share-view__with-csv-download" data-cy="nc-modal-share-view__with-csv-download"
data-nc="nc-modal-share-view__with-csv-download"
class="!text-sm" class="!text-sm"
> >
{{ $t('labels.downloadAllowed') }} {{ $t('labels.downloadAllowed') }}

5
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -53,7 +53,10 @@ watch(
</a-button> </a-button>
</div> </div>
<template #overlay> <template #overlay>
<div class="bg-gray-50 p-6 shadow-lg menu-filter-dropdown min-w-[400px] max-h-[max(80vh,500px)] overflow-auto !border"> <div
class="bg-gray-50 p-6 shadow-lg menu-filter-dropdown min-w-[400px] max-h-[max(80vh,500px)] overflow-auto !border"
data-nc="nc-sorts-menu"
>
<div v-if="sorts?.length" class="sort-grid mb-2" @click.stop> <div v-if="sorts?.length" class="sort-grid mb-2" @click.stop>
<template v-for="(sort, i) in sorts || []" :key="i"> <template v-for="(sort, i) in sorts || []" :key="i">
<MdiCloseBox class="nc-sort-item-remove-btn text-grey self-center" small @click.stop="deleteSort(sort, i)" /> <MdiCloseBox class="nc-sort-item-remove-btn text-grey self-center" small @click.stop="deleteSort(sort, i)" />

2
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -103,7 +103,7 @@ const { isSqlView } = useSmartsheetStoreOrThrow()
</a-button> </a-button>
<template #overlay> <template #overlay>
<a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded"> <a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded" data-nc="toolbar-actions">
<a-menu-item-group> <a-menu-item-group>
<a-sub-menu <a-sub-menu
v-if="isUIAllowed('view-type')" v-if="isUIAllowed('view-type')"

2
packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue

@ -136,7 +136,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full" data-nc="nc-share-base-sub-modal">
<div class="flex flex-row items-center space-x-0.5 pl-2 h-[0.8rem]"> <div class="flex flex-row items-center space-x-0.5 pl-2 h-[0.8rem]">
<MdiOpenInNew /> <MdiOpenInNew />

2
packages/nc-gui/components/webhook/Drawer.vue

@ -31,7 +31,7 @@ async function editHook(hook: Record<string, any>) {
class="nc-drawer-webhook" class="nc-drawer-webhook"
@keydown.esc="vModel = false" @keydown.esc="vModel = false"
> >
<a-layout> <a-layout class="nc-drawer-webhook-body">
<a-layout-content class="px-10 py-5 scrollbar-thin-primary"> <a-layout-content class="px-10 py-5 scrollbar-thin-primary">
<LazyWebhookEditor v-if="editOrAdd" :hook="currentHook" @back-to-list="editOrAdd = false" /> <LazyWebhookEditor v-if="editOrAdd" :hook="currentHook" @back-to-list="editOrAdd = false" />

2
packages/nc-gui/layouts/base.vue

@ -57,7 +57,7 @@ hooks.hook('page:finish', () => {
</div> </div>
<div class="!text-white flex justify-center"> <div class="!text-white flex justify-center">
<div v-show="isLoading" class="flex items-center gap-2 ml-3"> <div class="flex items-center gap-2 ml-3" data-nc="nc-loading">
{{ $t('general.loading') }} {{ $t('general.loading') }}
<MdiReload :class="{ 'animate-infinite animate-spin': isLoading }" /> <MdiReload :class="{ 'animate-infinite animate-spin': isLoading }" />

2
packages/nc-gui/layouts/shared-view.vue

@ -53,7 +53,7 @@ export default {
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<div class="flex items-center gap-2 ml-3 text-white"> <div class="flex items-center gap-2 ml-3 text-white">
<template v-if="isLoading"> <template v-if="isLoading">
<span class="text-white">{{ $t('general.loading') }}</span> <span class="text-white" data-nc="nc-loading">{{ $t('general.loading') }}</span>
<MdiReload :class="{ 'animate-infinite animate-spin ': isLoading }" /> <MdiReload :class="{ 'animate-infinite animate-spin ': isLoading }" />
</template> </template>

3
packages/nc-gui/lib/constants.ts

@ -4,7 +4,8 @@ export const NOCO = 'noco'
export const SYSTEM_COLUMNS = ['id', 'title', 'created_at', 'updated_at'] export const SYSTEM_COLUMNS = ['id', 'title', 'created_at', 'updated_at']
export const BASE_URL = process.env.NC_BACKEND_URL || (process.env.NODE_ENV === 'production' ? '..' : 'http://localhost:8080') export const BASE_URL =
import.meta.env.NC_BACKEND_URL || (import.meta.env.NODE_ENV === 'production' ? '..' : 'http://localhost:8080')
/** /**
* Each permission value means the following * Each permission value means the following

1
packages/nc-gui/pages/[projectType]/[projectId]/index.vue

@ -229,6 +229,7 @@ onBeforeUnmount(reset)
<div <div
:style="{ width: isOpen ? 'calc(100% - 40px) pr-2' : '100%' }" :style="{ width: isOpen ? 'calc(100% - 40px) pr-2' : '100%' }"
:class="[isOpen ? '' : 'justify-center']" :class="[isOpen ? '' : 'justify-center']"
data-nc="nc-project-menu"
class="group cursor-pointer flex gap-1 items-center nc-project-menu overflow-hidden" class="group cursor-pointer flex gap-1 items-center nc-project-menu overflow-hidden"
> >
<template v-if="isOpen"> <template v-if="isOpen">

6
packages/nc-gui/pages/[projectType]/[projectId]/index/index.vue

@ -52,14 +52,14 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
</div> </div>
<a-tooltip v-if="tab.title?.length > 12" placement="bottom"> <a-tooltip v-if="tab.title?.length > 12" placement="bottom">
<div class="truncate">{{ tab.title }}</div> <div class="truncate" :data-nc="`nc-root-tabs-${tab.title}`">{{ tab.title }}</div>
<template #title> <template #title>
<div>{{ tab.title }}</div> <div>{{ tab.title }}</div>
</template> </template>
</a-tooltip> </a-tooltip>
<div v-else>{{ tab.title }}</div> <div v-else :data-nc="`nc-root-tabs-${tab.title}`">{{ tab.title }}</div>
</div> </div>
</template> </template>
</a-tab-pane> </a-tab-pane>
@ -68,7 +68,7 @@ function onEdit(targetKey: number, action: 'add' | 'remove' | string) {
<span class="flex-1" /> <span class="flex-1" />
<div class="flex justify-center self-center mr-2 min-w-[115px]"> <div class="flex justify-center self-center mr-2 min-w-[115px]">
<div v-show="isLoading" class="flex items-center gap-2 ml-3 text-gray-200"> <div v-show="isLoading" class="flex items-center gap-2 ml-3 text-gray-200" data-nc="nc-loading">
{{ $t('general.loading') }} {{ $t('general.loading') }}
<MdiLoading class="animate-infinite animate-spin" /> <MdiLoading class="animate-infinite animate-spin" />

9
packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue

@ -85,6 +85,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
class="mt-0 nc-input" class="mt-0 nc-input"
:data-nc="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`" :class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
/> />
@ -93,6 +94,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
v-else v-else
v-model="formState[field.title]" v-model="formState[field.title]"
class="nc-input" class="nc-input"
:data-nc="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`" :class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
:edit-enabled="true" :edit-enabled="true"
@ -110,7 +112,12 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
</div> </div>
<div class="text-center mt-4"> <div class="text-center mt-4">
<button type="submit" class="uppercase scaling-btn prose-sm" @click="submitForm"> <button
type="submit"
class="uppercase scaling-btn prose-sm"
data-nc="shared-form-submit-button"
@click="submitForm"
>
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
</div> </div>

27
packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue

@ -208,12 +208,15 @@ onMounted(() => {
class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end" class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end"
> >
<div class="px-4 md:px-0 flex flex-col justify-end"> <div class="px-4 md:px-0 flex flex-col justify-end">
<h1 class="prose-2xl font-bold self-center my-4" data-cy="nc-survey-form__heading">{{ sharedFormView.heading }}</h1> <h1 class="prose-2xl font-bold self-center my-4" data-cy="nc-survey-form__heading" data-nc="nc-survey-form__heading">
{{ sharedFormView.heading }}
</h1>
<h2 <h2
v-if="sharedFormView.subheading && sharedFormView.subheading !== ''" v-if="sharedFormView.subheading && sharedFormView.subheading !== ''"
class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6" class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6"
data-cy="nc-survey-form__sub-heading" data-cy="nc-survey-form__sub-heading"
data-nc="nc-survey-form__sub-heading"
> >
{{ sharedFormView?.subheading }} {{ sharedFormView?.subheading }}
</h2> </h2>
@ -228,7 +231,7 @@ onMounted(() => {
class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto" class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto"
> >
<div v-if="field && !submitted" class="flex flex-col gap-2"> <div v-if="field && !submitted" class="flex flex-col gap-2">
<div class="flex nc-form-column-label"> <div class="flex nc-form-column-label" data-nc="nc-form-column-label">
<LazySmartsheetHeaderVirtualCell <LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }" :column="{ ...field, title: field.label || field.title }"
@ -252,6 +255,7 @@ onMounted(() => {
class="mt-0 nc-input" class="mt-0 nc-input"
:row="{ row: {}, oldRow: {}, rowMeta: {} }" :row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-nc="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
/> />
@ -260,6 +264,7 @@ onMounted(() => {
v-model="formState[field.title]" v-model="formState[field.title]"
class="nc-input" class="nc-input"
:data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-cy="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:data-nc="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
:edit-enabled="true" :edit-enabled="true"
/> />
@ -272,6 +277,7 @@ onMounted(() => {
<div <div
class="block text-[14px]" class="block text-[14px]"
:class="field.uidt === UITypes.Checkbox ? 'text-center' : ''" :class="field.uidt === UITypes.Checkbox ? 'text-center' : ''"
data-nc="nc-survey-form__field-description"
data-cy="nc-survey-form__field-description" data-cy="nc-survey-form__field-description"
> >
{{ field.description }} {{ field.description }}
@ -297,6 +303,7 @@ onMounted(() => {
type="submit" type="submit"
class="uppercase scaling-btn prose-sm" class="uppercase scaling-btn prose-sm"
data-cy="nc-survey-form__btn-submit" data-cy="nc-survey-form__btn-submit"
data-nc="nc-survey-form__btn-submit"
@click="submit" @click="submit"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
@ -312,6 +319,7 @@ onMounted(() => {
<button <button
class="bg-opacity-100 scaling-btn flex items-center gap-1" class="bg-opacity-100 scaling-btn flex items-center gap-1"
data-cy="nc-survey-form__btn-next" data-cy="nc-survey-form__btn-next"
data-nc="nc-survey-form__btn-next"
:class="[ :class="[
v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : '', v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : '',
animationTarget === AnimationTarget.OkButton && isAnimating animationTarget === AnimationTarget.OkButton && isAnimating
@ -341,7 +349,11 @@ onMounted(() => {
<Transition name="slide-left"> <Transition name="slide-left">
<div v-if="submitted" class="flex flex-col justify-center items-center text-center"> <div v-if="submitted" class="flex flex-col justify-center items-center text-center">
<div class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded" data-cy="nc-survey-form__success-msg"> <div
class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded"
data-cy="nc-survey-form__success-msg"
data-nc="nc-survey-form__success-msg"
>
<template v-if="sharedFormView?.success_msg"> <template v-if="sharedFormView?.success_msg">
{{ sharedFormView?.success_msg }} {{ sharedFormView?.success_msg }}
</template> </template>
@ -365,6 +377,7 @@ onMounted(() => {
type="button" type="button"
class="scaling-btn bg-opacity-100" class="scaling-btn bg-opacity-100"
data-cy="nc-survey-form__btn-submit-another-form" data-cy="nc-survey-form__btn-submit-another-form"
data-nc="nc-survey-form__btn-submit-another-form"
@click="resetForm" @click="resetForm"
> >
Submit Another Form Submit Another Form
@ -378,7 +391,11 @@ onMounted(() => {
</div> </div>
<template v-if="!submitted"> <template v-if="!submitted">
<div class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200" data-cy="nc-survey-form__footer"> <div
class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200"
data-cy="nc-survey-form__footer"
data-nc="nc-survey-form__footer"
>
{{ index + 1 }} / {{ formColumns?.length }} {{ index + 1 }} / {{ formColumns?.length }}
</div> </div>
</template> </template>
@ -398,6 +415,7 @@ onMounted(() => {
" "
class="p-0.5 flex items-center group color-transition" class="p-0.5 flex items-center group color-transition"
data-cy="nc-survey-form__icon-prev" data-cy="nc-survey-form__icon-prev"
data-nc="nc-survey-form__icon-prev"
@click="goPrevious()" @click="goPrevious()"
> >
<MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" /> <MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" />
@ -417,6 +435,7 @@ onMounted(() => {
" "
class="p-0.5 flex items-center group color-transition" class="p-0.5 flex items-center group color-transition"
data-cy="nc-survey-form__icon-next" data-cy="nc-survey-form__icon-next"
data-nc="nc-survey-form__icon-next"
@click="goNext()" @click="goNext()"
> >
<MdiChevronRight <MdiChevronRight

12
packages/nc-gui/pages/index/index/index.vue

@ -142,7 +142,10 @@ const copyProjectMeta = async () => {
</script> </script>
<template> <template>
<div class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"> <div
class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
data-nc="projects-container"
>
<h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4"> <h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4">
<span class="text-4xl nc-project-page-title" @dblclick="copyProjectMeta">{{ $t('title.myProject') }}</span> <span class="text-4xl nc-project-page-title" @dblclick="copyProjectMeta">{{ $t('title.myProject') }}</span>
</h1> </h1>
@ -163,6 +166,7 @@ const copyProjectMeta = async () => {
v-e="['a:project:refresh']" v-e="['a:project:refresh']"
class="text-xl text-gray-500 group-hover:text-accent cursor-pointer" class="text-xl text-gray-500 group-hover:text-accent cursor-pointer"
:class="isLoading ? '!text-primary' : ''" :class="isLoading ? '!text-primary' : ''"
data-nc="projects-reload-button"
@click="loadProjects" @click="loadProjects"
/> />
</div> </div>
@ -284,7 +288,11 @@ const copyProjectMeta = async () => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<MdiEditOutline v-e="['c:project:edit:rename']" class="nc-action-btn" @click.stop="navigateTo(`/${text}`)" /> <MdiEditOutline v-e="['c:project:edit:rename']" class="nc-action-btn" @click.stop="navigateTo(`/${text}`)" />
<MdiDeleteOutline class="nc-action-btn" @click.stop="deleteProject(record)" /> <MdiDeleteOutline
class="nc-action-btn"
:data-nc="`delete-project-${record.title}`"
@click.stop="deleteProject(record)"
/>
</div> </div>
</template> </template>
</a-table-column> </a-table-column>

9
packages/nc-gui/pages/signin.vue

@ -88,6 +88,7 @@ function resetError() {
<a-input <a-input
v-model:value="form.email" v-model:value="form.email"
data-cy="nc-form-signin__email" data-cy="nc-form-signin__email"
data-nc="nc-form-signin__email"
size="large" size="large"
:placeholder="$t('msg.info.signUp.workEmail')" :placeholder="$t('msg.info.signUp.workEmail')"
@focus="resetError" @focus="resetError"
@ -98,6 +99,7 @@ function resetError() {
<a-input-password <a-input-password
v-model:value="form.password" v-model:value="form.password"
data-cy="nc-form-signin__password" data-cy="nc-form-signin__password"
data-nc="nc-form-signin__password"
size="large" size="large"
class="password" class="password"
:placeholder="$t('msg.info.signUp.enterPassword')" :placeholder="$t('msg.info.signUp.enterPassword')"
@ -112,7 +114,12 @@ function resetError() {
</div> </div>
<div class="self-center flex flex-col flex-wrap gap-4 items-center mt-4 justify-center"> <div class="self-center flex flex-col flex-wrap gap-4 items-center mt-4 justify-center">
<button data-cy="nc-form-signin__submit" class="scaling-btn bg-opacity-100" type="submit"> <button
data-cy="nc-form-signin__submit"
data-nc="nc-form-signin__submit"
class="scaling-btn bg-opacity-100"
type="submit"
>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<MdiLogin /> <MdiLogin />
{{ $t('general.signIn') }} {{ $t('general.signIn') }}

3
packages/nocodb/package.json

@ -36,6 +36,9 @@
"docker:build": "EE=\"true-xc-test\" webpack --config docker/webpack.config.js", "docker:build": "EE=\"true-xc-test\" webpack --config docker/webpack.config.js",
"watch:build": "nodemon -e ts,js -w ./src -x npm run build", "watch:build": "nodemon -e ts,js -w ./src -x npm run build",
"watch:run": "cross-env NC_DISABLE_TELE1=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"", "watch:run": "cross-env NC_DISABLE_TELE1=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:playwright": "cross-env PLAYWRIGHT_TEST=true NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright:quick": "rm -f ./test_noco.db; cp ../../scripts/cypress/fixtures/quickTest/noco_0_91_7.db ./test_noco.db; cross-env DATABASE_URL=sqlite:./test_noco.db NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:playwright:pg:cyquick": "cross-env EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG_CyQuick.ts --log-error --project tsconfig.json\"",
"watch:run:cypress": "cross-env EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"", "watch:run:cypress": "cross-env EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:cypress:pg": "cross-env EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG --log-error --project tsconfig.json\"", "watch:run:cypress:pg": "cross-env EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG --log-error --project tsconfig.json\"",
"watch:run:cypress:pg:cyquick": "cross-env EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG_CyQuick.ts --log-error --project tsconfig.json\"", "watch:run:cypress:pg:cyquick": "cross-env EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG_CyQuick.ts --log-error --project tsconfig.json\"",

4
packages/nocodb/src/lib/meta/api/index.ts

@ -28,6 +28,7 @@ import metaDiffApis from './metaDiffApis';
import cacheApis from './cacheApis'; import cacheApis from './cacheApis';
import apiTokenApis from './apiTokenApis'; import apiTokenApis from './apiTokenApis';
import hookFilterApis from './hookFilterApis'; import hookFilterApis from './hookFilterApis';
import testApis from './testApis';
import { import {
bulkDataAliasApis, bulkDataAliasApis,
dataAliasApis, dataAliasApis,
@ -57,6 +58,9 @@ export default function (router: Router, server) {
projectApis(router); projectApis(router);
utilApis(router); utilApis(router);
if(process.env['PLAYWRIGHT_TEST'] === 'true') {
router.use(testApis);
}
router.use(columnApis); router.use(columnApis);
router.use(exportApis); router.use(exportApis);
router.use(dataApis); router.use(dataApis);

36
packages/nocodb/src/lib/meta/api/testApis.ts

@ -0,0 +1,36 @@
import Noco from '../../Noco';
import { Request, Router } from 'express';
import { TestResetService } from '../../services/test/TestResetService';
export async function reset(req: Request<any, any>, res) {
const service = new TestResetService({
parallelId: req.body.parallelId,
dbType: req.body.dbType,
isEmptyProject: req.body.isEmptyProject,
});
res.json(await service.process());
}
export async function sqliteExec(req: Request<any, any>, res) {
const metaKnex = Noco.ncMeta.knex;
try {
const result = await metaKnex.raw(req.body.sql);
res.json({
body: result,
});
} catch (e) {
console.error('sqliteExec', e);
res.status(500).json({
error: e,
});
}
}
const router = Router();
router.post('/api/v1/meta/test/reset', reset);
router.post('/api/v1/meta/test/sqlite_exec', sqliteExec);
export default router;

55
packages/nocodb/src/lib/models/User.ts

@ -3,6 +3,7 @@ import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import Noco from '../Noco'; import Noco from '../Noco';
import { extractProps } from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import { NcError } from '../meta/helpers/catchError';
export default class User implements UserType { export default class User implements UserType {
id: string; id: string;
@ -159,4 +160,58 @@ export default class User implements UserType {
}); });
return user; return user;
} }
public static async list(
{
limit,
offset,
query,
}: {
limit?: number | undefined;
offset?: number | undefined;
query?: string;
} = {},
ncMeta = Noco.ncMeta
) {
let queryBuilder = ncMeta.knex(MetaTable.USERS);
if (offset) queryBuilder = queryBuilder.offset(offset);
if (limit) queryBuilder = queryBuilder.limit(limit);
queryBuilder = queryBuilder
.select(
`${MetaTable.USERS}.id`,
`${MetaTable.USERS}.email`,
`${MetaTable.USERS}.firstname`,
`${MetaTable.USERS}.lastname`,
`${MetaTable.USERS}.username`,
`${MetaTable.USERS}.email_verified`,
`${MetaTable.USERS}.created_at`,
`${MetaTable.USERS}.updated_at`,
`${MetaTable.USERS}.invite_token`,
`${MetaTable.USERS}.roles`
)
.select(
ncMeta
.knex(MetaTable.PROJECT_USERS)
.count()
.whereRaw(
`${MetaTable.USERS}.id = ${MetaTable.PROJECT_USERS}.fk_user_id`
)
.as('projectsCount')
);
if (query) {
queryBuilder.where('email', 'like', `%${query.toLowerCase?.()}%`);
}
return queryBuilder;
}
static async delete(userId: string, ncMeta = Noco.ncMeta) {
if (!userId) NcError.badRequest('userId is required');
await NocoCache.delAll(CacheScope.USER, `${userId}___*`);
await NocoCache.del(`${CacheScope.USER}:${userId}`);
await ncMeta.metaDelete(null, null, MetaTable.USERS, userId);
}
} }

174
packages/nocodb/src/lib/services/test/TestResetService/index.ts

@ -0,0 +1,174 @@
import Noco from '../../../Noco';
import Knex from 'knex';
import axios from 'axios';
import Project from '../../../models/Project';
import NcConnectionMgrv2 from '../../../utils/common/NcConnectionMgrv2';
import resetMetaSakilaSqliteProject from './resetMetaSakilaSqliteProject';
import resetMysqlSakilaProject from './resetMysqlSakilaProject';
import Model from '../../../models/Model';
import resetPgSakilaProject from './resetPgSakilaProject';
import User from '../../../models/User';
import NocoCache from '../../../cache/NocoCache';
import { CacheScope } from '../../../utils/globals';
import ProjectUser from '../../../models/ProjectUser';
const loginRootUser = async () => {
const response = await axios.post(
'http://localhost:8080/api/v1/auth/user/signin',
{ email: 'user@nocodb.com', password: 'Password123.' }
);
return response.data.token;
};
const projectTitleByType = {
sqlite: 'sampleREST',
mysql: 'externalREST',
pg: 'pgExtREST',
};
export class TestResetService {
private knex: Knex | null = null;
private readonly parallelId;
private readonly dbType;
private readonly isEmptyProject: boolean;
constructor({
parallelId,
dbType,
isEmptyProject,
}: {
parallelId: string;
dbType: string;
isEmptyProject: boolean;
}) {
this.knex = Noco.ncMeta.knex;
this.parallelId = parallelId;
this.dbType = dbType;
this.isEmptyProject = isEmptyProject;
}
async process() {
try {
const token = await loginRootUser();
const { project } = await this.resetProject({
metaKnex: this.knex,
token,
dbType: this.dbType,
parallelId: this.parallelId,
});
await removeAllPrefixedUsersExceptSuper(this.parallelId);
return { token, project };
} catch (e) {
console.error('TestResetService:process', e);
return { error: e };
}
}
async resetProject({
metaKnex,
token,
dbType,
parallelId,
}: {
metaKnex: Knex;
token: string;
dbType: string;
parallelId: string;
}) {
const title = `${projectTitleByType[dbType]}${parallelId}`;
const project: Project | undefined = await Project.getByTitle(title);
if (project) {
await removeProjectUsersFromCache(project);
const bases = await project.getBases();
if (dbType == 'sqlite') await dropTablesOfProject(metaKnex, project);
await Project.delete(project.id);
if (bases.length > 0) await NcConnectionMgrv2.deleteAwait(bases[0]);
}
if (dbType == 'sqlite') {
await resetMetaSakilaSqliteProject({
token,
metaKnex,
title,
oldProject: project,
isEmptyProject: this.isEmptyProject,
});
} else if (dbType == 'mysql') {
await resetMysqlSakilaProject({
token,
title,
parallelId,
oldProject: project,
isEmptyProject: this.isEmptyProject,
});
} else if (dbType == 'pg') {
await resetPgSakilaProject({
token,
title,
parallelId,
oldProject: project,
isEmptyProject: this.isEmptyProject,
});
}
return {
project: await Project.getByTitle(title),
};
}
}
const dropTablesOfProject = async (knex: Knex, project: Project) => {
const tables = await Model.list({
project_id: project.id,
base_id: (await project.getBases())[0].id,
});
for (const table of tables) {
if (table.type == 'table') {
await knex.raw(`DROP TABLE IF EXISTS ${table.table_name}`);
} else {
await knex.raw(`DROP VIEW IF EXISTS ${table.table_name}`);
}
}
};
const removeAllPrefixedUsersExceptSuper = async (parallelId: string) => {
const users = (await User.list()).filter(
(user) => !user.roles.includes('super')
);
for (const user of users) {
if(user.email.startsWith(`nc_test_${parallelId}_`)) {
await NocoCache.del(`${CacheScope.USER}:${user.email}`);
await User.delete(user.id);
}
}
};
// todo: Remove this once user deletion improvement PR is merged
const removeProjectUsersFromCache = async (project: Project) => {
const projectUsers: ProjectUser[] = await ProjectUser.getUsersList({
project_id: project.id,
limit: 1000,
offset: 0,
});
for (const projectUser of projectUsers) {
try {
const user: User = await User.get(projectUser.fk_user_id);
await NocoCache.del(
`${CacheScope.PROJECT_USER}:${project.id}:${user.id}`
);
} catch (e) {
console.error('removeProjectUsersFromCache', e);
}
}
};

151
packages/nocodb/src/lib/services/test/TestResetService/resetMetaSakilaSqliteProject.ts

@ -0,0 +1,151 @@
import axios from 'axios';
import Knex from 'knex';
import { promises as fs } from 'fs';
import { sakilaTableNames } from '../../../utils/globals';
import Project from '../../../models/Project';
const sqliteSakilaSqlViews = [
'actor_info',
'customer_list',
'film_list',
'nice_but_slower_film_list',
'sales_by_film_category',
'sales_by_store',
'staff_list',
];
const resetMetaSakilaSqliteProject = async ({
metaKnex,
token,
title,
oldProject,
isEmptyProject,
}: {
metaKnex: Knex;
token: string;
title: string;
oldProject: Project;
isEmptyProject: boolean;
}) => {
const project = await createProject(token, title);
if (oldProject) await dropTablesAndViews(metaKnex, oldProject.prefix);
await dropTablesAndViews(metaKnex, project.prefix);
if (isEmptyProject) return;
await resetMetaSakilaSqlite(metaKnex, project.prefix, oldProject);
await syncMeta(project, token);
};
const createProject = async (token: string, title: string) => {
const response = await axios.post(
'http://localhost:8080/api/v1/db/meta/projects/',
{ title },
{
headers: {
'xc-auth': token,
},
}
);
if (response.status !== 200) {
console.error('Error creating project', response.data);
}
return response.data;
};
const syncMeta = async (project: Project, token: string) => {
await axios.post(
`http://localhost:8080/api/v1/db/meta/projects/${project.id}/meta-diff`,
{},
{
headers: {
'xc-auth': token,
},
}
);
};
const dropTablesAndViews = async (metaKnex: Knex, prefix: string) => {
try {
for (const view of sqliteSakilaSqlViews) {
await metaKnex.raw(`DROP VIEW IF EXISTS ${prefix}${view}`);
}
for (const table of sakilaTableNames) {
await metaKnex.raw(`DROP TABLE IF EXISTS ${prefix}${table}`);
}
} catch (e) {
console.error('Error dropping tables and views', e);
}
};
const resetMetaSakilaSqlite = async (
metaKnex: Knex,
prefix: string,
oldProject: Project
) => {
await dropTablesAndViews(metaKnex, oldProject.prefix);
const testsDir = __dirname.replace(
'/src/lib/services/test/TestResetService',
'/tests'
);
try {
const schemaFile = await fs.readFile(
`${testsDir}/sqlite-sakila-db/03-sqlite-prefix-sakila-schema.sql`
);
const schemaFileStr = schemaFile.toString().replace(/prefix___/g, prefix);
const schemaSqlQueries = schemaFileStr
.split(';')
.filter((str) => str.trim().length > 0)
.map((str) => str.trim());
for (const sqlQuery of schemaSqlQueries) {
if (sqlQuery.trim().length > 0) {
await metaKnex.raw(
sqlQuery
.trim()
.replace(/WHERE rowid = new.rowid/g, '$&;')
);
}
}
} catch (e) {
console.error('Error resetting meta sakila sqlite:db', e);
}
const dataFile = await fs.readFile(
`${testsDir}/sqlite-sakila-db/04-sqlite-prefix-sakila-insert-data.sql`
);
const dataFileStr = dataFile.toString().replace(/prefix___/g, prefix);
const dataSqlQueries = dataFileStr
.split(';')
.filter((str) => str.trim().length > 0)
.map((str) => str.trim());
const batchSize = 1000;
const batches = dataSqlQueries.reduce((acc, _, i) => {
if (!(i % batchSize)) {
// if index is 0 or can be divided by the `size`...
acc.push(dataSqlQueries.slice(i, i + batchSize)); // ..push a chunk of the original array to the accumulator
}
return acc;
}, []);
for (const sqlQueryBatch of batches) {
const trx = await metaKnex.transaction();
for (const sqlQuery of sqlQueryBatch) {
await trx.raw(sqlQuery);
}
await trx.commit();
// wait for 40 ms to avoid SQLITE_BUSY error
await new Promise((resolve) => setTimeout(resolve, 40));
}
};
export default resetMetaSakilaSqliteProject;

154
packages/nocodb/src/lib/services/test/TestResetService/resetMysqlSakilaProject.ts

@ -0,0 +1,154 @@
import axios from 'axios';
import Knex from 'knex';
import { promises as fs } from 'fs';
import Audit from '../../../models/Audit';
import Project from '../../../models/Project';
const config = {
client: 'mysql2',
connection: {
host: 'localhost',
port: 3306,
user: 'root',
password: 'password',
database: 'sakila',
multipleStatements: true,
dateStrings: true,
},
};
const extMysqlProject = (title, parallelId) => ({
title,
bases: [
{
type: 'mysql2',
config: {
client: 'mysql2',
connection: {
host: 'localhost',
port: '3306',
user: 'root',
password: 'password',
database: `test_sakila_${parallelId}`,
},
},
inflection_column: 'camelize',
inflection_table: 'camelize',
},
],
external: true,
});
const isSakilaMysqlToBeReset = async (
knex: Knex,
parallelId: string,
project?: Project
) => {
const tablesInDb: Array<string> = await knex.raw(
`SELECT table_name FROM information_schema.tables WHERE table_schema = 'test_sakila_${parallelId}'`
);
if (
tablesInDb.length === 0 ||
(tablesInDb.length > 0 && !tablesInDb.includes(`actor`))
) {
return true;
}
if (!project) return false;
const audits = await Audit.projectAuditList(project.id, {});
return audits?.length > 0;
};
const resetSakilaMysql = async (
knex: Knex,
parallelId: string,
isEmptyProject: boolean
) => {
const testsDir = __dirname.replace(
'/src/lib/services/test/TestResetService',
'/tests'
);
try {
await knex.raw(`DROP DATABASE test_sakila_${parallelId}`);
} catch (e) {
console.log('Error dropping db', e);
}
await knex.raw(`CREATE DATABASE test_sakila_${parallelId}`);
if (isEmptyProject) return;
const trx = await knex.transaction();
try {
const schemaFile = await fs.readFile(
`${testsDir}/mysql-sakila-db/03-test-sakila-schema.sql`
);
const dataFile = await fs.readFile(
`${testsDir}/mysql-sakila-db/04-test-sakila-data.sql`
);
await trx.raw(
schemaFile.toString().replace(/test_sakila/g, `test_sakila_${parallelId}`)
);
await trx.raw(
dataFile.toString().replace(/test_sakila/g, `test_sakila_${parallelId}`)
);
await trx.commit();
} catch (e) {
console.log('Error resetting mysql db', e);
await trx.rollback(e);
}
};
const resetMysqlSakilaProject = async ({
token,
title,
parallelId,
oldProject,
isEmptyProject,
}: {
token: string;
title: string;
parallelId: string;
oldProject?: Project | undefined;
isEmptyProject: boolean;
}) => {
const knex = Knex(config);
try {
await knex.raw(`USE test_sakila_${parallelId}`);
} catch (e) {
await knex.raw(`CREATE DATABASE test_sakila_${parallelId}`);
await knex.raw(`USE test_sakila_${parallelId}`);
}
if (
isEmptyProject ||
(await isSakilaMysqlToBeReset(knex, parallelId, oldProject))
) {
await resetSakilaMysql(knex, parallelId, isEmptyProject);
}
const response = await axios.post(
'http://localhost:8080/api/v1/db/meta/projects/',
extMysqlProject(title, parallelId),
{
headers: {
'xc-auth': token,
},
}
);
if (response.status !== 200) {
console.error('Error creating project', response.data);
}
await knex.destroy();
};
export default resetMysqlSakilaProject;

152
packages/nocodb/src/lib/services/test/TestResetService/resetPgSakilaProject.ts

@ -0,0 +1,152 @@
import axios from 'axios';
import Knex from 'knex';
import { promises as fs } from 'fs';
const util = require('util');
const exec = util.promisify(require('child_process').exec);
import Audit from '../../../models/Audit';
import Project from '../../../models/Project';
const config = {
client: 'pg',
connection: {
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'password',
database: 'postgres',
multipleStatements: true,
},
searchPath: ['public', 'information_schema'],
pool: { min: 0, max: 5 },
};
const extMysqlProject = (title, parallelId) => ({
title,
bases: [
{
type: 'pg',
config: {
client: 'pg',
connection: {
host: 'localhost',
port: '5432',
user: 'postgres',
password: 'password',
database: `sakila_${parallelId}`,
},
searchPath: ['public'],
},
inflection_column: 'camelize',
inflection_table: 'camelize',
},
],
external: true,
});
const isSakilaPgToBeReset = async (knex: Knex, project?: Project) => {
const tablesInDb: Array<string> = (
await knex.raw(
`SELECT * FROM information_schema.tables WHERE table_schema = 'public'`
)
).rows.map((row) => row.table_name);
if (
tablesInDb.length === 0 ||
(tablesInDb.length > 0 && !tablesInDb.includes(`actor`))
) {
return true;
}
if (!project) return false;
const audits = await Audit.projectAuditList(project.id, {});
return audits?.length > 0;
};
const resetSakilaPg = async (
pgknex: Knex,
parallelId: string,
isEmptyProject: boolean
) => {
const testsDir = __dirname.replace(
'/src/lib/services/test/TestResetService',
'/tests'
);
await pgknex.raw(`DROP DATABASE IF EXISTS sakila_${parallelId}`);
await pgknex.raw(`CREATE DATABASE sakila_${parallelId}`);
if (isEmptyProject) return;
const sakilaKnex = Knex(sakilaKnexConfig(parallelId));
const schemaFile = await fs.readFile(
`${testsDir}/pg-sakila-db/03-postgres-sakila-schema.sql`
);
await sakilaKnex.raw(schemaFile.toString());
const dataFilePath = `${testsDir}/pg-sakila-db/04-postgres-sakila-insert-data.sql`;
await exec(
`export PGPASSWORD='${config.connection.password}';psql sakila_${parallelId} -h localhost -U postgres -w -f ${dataFilePath}`
);
await sakilaKnex.destroy();
};
const sakilaKnexConfig = (parallelId: string) => ({
...config,
connection: {
...config.connection,
database: `sakila_${parallelId}`,
},
});
const resetPgSakilaProject = async ({
token,
title,
parallelId,
oldProject,
isEmptyProject,
}: {
token: string;
title: string;
parallelId: string;
oldProject?: Project | undefined;
isEmptyProject: boolean;
}) => {
const pgknex = Knex(config);
try {
await pgknex.raw(`CREATE DATABASE sakila_${parallelId}`);
} catch (e) {}
const sakilaKnex = Knex(sakilaKnexConfig(parallelId));
if (isEmptyProject || (await isSakilaPgToBeReset(sakilaKnex, oldProject))) {
await sakilaKnex.destroy();
await resetSakilaPg(pgknex, parallelId, isEmptyProject);
} else {
await sakilaKnex.destroy();
}
const response = await axios.post(
'http://localhost:8080/api/v1/db/meta/projects/',
extMysqlProject(title, parallelId),
{
headers: {
'xc-auth': token,
},
}
);
if (response.status !== 200) {
console.error('Error creating project', response.data);
throw new Error('Error creating project', response.data);
}
await pgknex.destroy();
};
export default resetPgSakilaProject;

13
packages/nocodb/src/lib/utils/common/NcConnectionMgrv2.ts

@ -47,6 +47,19 @@ export default class NcConnectionMgrv2 {
} }
} }
public static async deleteAwait(base: Base) {
// todo: ignore meta projects
if (this.connectionRefs?.[base.project_id]?.[base.id]) {
try {
const conn = this.connectionRefs?.[base.project_id]?.[base.id];
await conn.destroy();
delete this.connectionRefs?.[base.project_id][base.id];
} catch (e) {
console.log(e);
}
}
}
public static get(base: Base): XKnex { public static get(base: Base): XKnex {
if (base.is_meta) return Noco.ncMeta.knex; if (base.is_meta) return Noco.ncMeta.knex;

26
packages/nocodb/src/lib/utils/globals.ts

@ -74,6 +74,32 @@ export const orderedMetaTables = [
MetaTable.PROJECT, MetaTable.PROJECT,
]; ];
export const sakilaTableNames = [
'actor',
'address',
'category',
'city',
'country',
'customer',
'film',
'film_actor',
'film_category',
'film_text',
'inventory',
'language',
'payment',
'rental',
'staff',
'store',
'actor_info',
'customer_list',
'film_list',
'nicer_but_slower_film_list',
'sales_by_film_category',
'sales_by_store',
'staff_list',
];
export enum CacheScope { export enum CacheScope {
PROJECT = 'project', PROJECT = 'project',
BASE = 'base', BASE = 'base',

42
packages/nocodb/src/run/testDocker.ts

@ -0,0 +1,42 @@
import axios from 'axios';
import cors from 'cors';
import express from 'express';
import Noco from '../lib/Noco';
import User from '../lib/models/User';
process.env.NC_VERSION = '0009044';
const server = express();
server.enable('trust proxy');
server.disable('etag');
server.disable('x-powered-by');
server.use(
cors({
exposedHeaders: 'xc-db-response',
})
);
server.set('view engine', 'ejs');
process.env[`DEBUG`] = 'xc*';
(async () => {
const httpServer = server.listen(process.env.PORT || 8080, () => {
console.log(`App started successfully.\nVisit -> ${Noco.dashboardUrl}`);
});
server.use(await Noco.init({}, httpServer, server));
// Wait for 0.5 seconds for the server to start
await new Promise((resolve) => setTimeout(resolve, 500));
if (!(await User.getByEmail('user@nocodb.com'))) {
const response = await axios.post(
`http://localhost:${process.env.PORT || 8080}/api/v1/auth/user/signup`,
{
email: 'user@nocodb.com',
password: 'Password123.',
}
);
console.log(response.data);
}
})().catch((e) => console.log(e));

1710
packages/nocodb/tests/pg-sakila-db/03-postgres-sakila-schema.sql

File diff suppressed because it is too large Load Diff

46702
packages/nocodb/tests/pg-sakila-db/04-postgres-sakila-insert-data.sql

File diff suppressed because it is too large Load Diff

604
packages/nocodb/tests/sqlite-sakila-db/03-sqlite-prefix-sakila-schema.sql

@ -0,0 +1,604 @@
/*
Sakila for SQLite is a port of the Sakila example database available for MySQL, which was originally developed by Mike Hillyer of the MySQL AB documentation team.
This project is designed to help database administrators to decide which database to use for development of new products
The user can run the same SQL against different kind of databases and compare the performance
License: BSD
Copyright DB Software Laboratory
http://www.etl-tools.com
*/
CREATE TABLE prefix___actor (
actor_id numeric NOT NULL ,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (actor_id)
) ;
CREATE INDEX prefix___idx_actor_last_name ON prefix___actor(last_name)
;
CREATE TRIGGER prefix___actor_trigger_ai AFTER INSERT ON prefix___actor
BEGIN
UPDATE prefix___actor SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___actor_trigger_au AFTER UPDATE ON prefix___actor
BEGIN
UPDATE prefix___actor SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table country
--
CREATE TABLE prefix___country (
country_id SMALLINT NOT NULL,
country VARCHAR(50) NOT NULL,
last_update TIMESTAMP,
PRIMARY KEY (country_id)
)
;
CREATE TRIGGER prefix___country_trigger_ai AFTER INSERT ON prefix___country
BEGIN
UPDATE prefix___country SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___country_trigger_au AFTER UPDATE ON prefix___country
BEGIN
UPDATE prefix___country SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table city
--
CREATE TABLE prefix___city (
city_id int NOT NULL,
city VARCHAR(50) NOT NULL,
country_id SMALLINT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (city_id),
CONSTRAINT prefix___fk_city_country FOREIGN KEY (country_id) REFERENCES prefix___country (country_id) ON DELETE NO ACTION ON UPDATE CASCADE
)
;
CREATE INDEX prefix___idx_fk_country_id ON prefix___city(country_id)
;
CREATE TRIGGER prefix___city_trigger_ai AFTER INSERT ON prefix___city
BEGIN
UPDATE prefix___city SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___city_trigger_au AFTER UPDATE ON prefix___city
BEGIN
UPDATE prefix___city SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table address
--
CREATE TABLE prefix___address (
address_id int NOT NULL,
address VARCHAR(50) NOT NULL,
address2 VARCHAR(50) DEFAULT NULL,
district VARCHAR(20) NOT NULL,
city_id INT NOT NULL,
postal_code VARCHAR(10) DEFAULT NULL,
phone VARCHAR(20) NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (address_id),
CONSTRAINT prefix___fk_address_city FOREIGN KEY (city_id) REFERENCES prefix___city (city_id) ON DELETE NO ACTION ON UPDATE CASCADE
)
;
CREATE INDEX prefix___idx_fk_city_id ON prefix___address(city_id)
;
CREATE TRIGGER prefix___address_trigger_ai AFTER INSERT ON prefix___address
BEGIN
UPDATE prefix___address SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___address_trigger_au AFTER UPDATE ON prefix___address
BEGIN
UPDATE prefix___address SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table language
--
CREATE TABLE prefix___language (
language_id SMALLINT NOT NULL ,
name CHAR(20) NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (language_id)
)
;
CREATE TRIGGER prefix___language_trigger_ai AFTER INSERT ON prefix___language
BEGIN
UPDATE prefix___language SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___language_trigger_au AFTER UPDATE ON prefix___language
BEGIN
UPDATE prefix___language SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table category
--
CREATE TABLE prefix___category (
category_id SMALLINT NOT NULL,
name VARCHAR(25) NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (category_id)
);
CREATE TRIGGER prefix___category_trigger_ai AFTER INSERT ON prefix___category
BEGIN
UPDATE prefix___category SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___category_trigger_au AFTER UPDATE ON prefix___category
BEGIN
UPDATE prefix___category SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table customer
--
CREATE TABLE prefix___customer (
customer_id INT NOT NULL,
store_id INT NOT NULL,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
email VARCHAR(50) DEFAULT NULL,
address_id INT NOT NULL,
active CHAR(1) DEFAULT 'Y' NOT NULL,
create_date TIMESTAMP NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (customer_id),
CONSTRAINT prefix___fk_customer_store FOREIGN KEY (store_id) REFERENCES prefix___store (store_id) ON DELETE NO ACTION ON UPDATE CASCADE,
CONSTRAINT prefix___fk_customer_address FOREIGN KEY (address_id) REFERENCES prefix___address (address_id) ON DELETE NO ACTION ON UPDATE CASCADE
)
;
CREATE INDEX prefix___idx_customer_fk_store_id ON prefix___customer(store_id)
;
CREATE INDEX prefix___idx_customer_fk_address_id ON prefix___customer(address_id)
;
CREATE INDEX prefix___idx_customer_last_name ON prefix___customer(last_name)
;
CREATE TRIGGER prefix___customer_trigger_ai AFTER INSERT ON prefix___customer
BEGIN
UPDATE prefix___customer SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___customer_trigger_au AFTER UPDATE ON prefix___customer
BEGIN
UPDATE prefix___customer SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table film
--
CREATE TABLE prefix___film (
film_id int NOT NULL,
title VARCHAR(255) NOT NULL,
description BLOB SUB_TYPE TEXT DEFAULT NULL,
release_year VARCHAR(4) DEFAULT NULL,
language_id SMALLINT NOT NULL,
original_language_id SMALLINT DEFAULT NULL,
rental_duration SMALLINT DEFAULT 3 NOT NULL,
rental_rate DECIMAL(4,2) DEFAULT 4.99 NOT NULL,
length SMALLINT DEFAULT NULL,
replacement_cost DECIMAL(5,2) DEFAULT 19.99 NOT NULL,
rating VARCHAR(10) DEFAULT 'G',
special_features VARCHAR(100) DEFAULT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (film_id),
CONSTRAINT CHECK_special_features CHECK(special_features is null or
special_features like '%Trailers%' or
special_features like '%Commentaries%' or
special_features like '%Deleted Scenes%' or
special_features like '%Behind the Scenes%'),
CONSTRAINT CHECK_special_rating CHECK(rating in ('G','PG','PG-13','R','NC-17')),
CONSTRAINT prefix___fk_film_language FOREIGN KEY (language_id) REFERENCES prefix___language (language_id) ,
CONSTRAINT prefix___fk_film_language_original FOREIGN KEY (original_language_id) REFERENCES prefix___language (language_id)
)
;
CREATE INDEX prefix___idx_fk_language_id ON prefix___film(language_id)
;
CREATE INDEX prefix___idx_fk_original_language_id ON prefix___film(original_language_id)
;
CREATE TRIGGER prefix___film_trigger_ai AFTER INSERT ON prefix___film
BEGIN
UPDATE prefix___film SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___film_trigger_au AFTER UPDATE ON prefix___film
BEGIN
UPDATE prefix___film SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table film_actor
--
CREATE TABLE prefix___film_actor (
actor_id INT NOT NULL,
film_id INT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (actor_id,film_id),
CONSTRAINT prefix___fk_film_actor_actor FOREIGN KEY (actor_id) REFERENCES prefix___actor (actor_id) ON DELETE NO ACTION ON UPDATE CASCADE,
CONSTRAINT prefix___fk_film_actor_film FOREIGN KEY (film_id) REFERENCES prefix___film (film_id) ON DELETE NO ACTION ON UPDATE CASCADE
)
;
CREATE INDEX prefix___idx_fk_film_actor_film ON prefix___film_actor(film_id)
;
CREATE INDEX prefix___idx_fk_film_actor_actor ON prefix___film_actor(actor_id)
;
CREATE TRIGGER prefix___film_actor_trigger_ai AFTER INSERT ON prefix___film_actor
BEGIN
UPDATE prefix___film_actor SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___film_actor_trigger_au AFTER UPDATE ON prefix___film_actor
BEGIN
UPDATE prefix___film_actor SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table film_category
--
CREATE TABLE prefix___film_category (
film_id INT NOT NULL,
category_id SMALLINT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (film_id, category_id),
CONSTRAINT prefix___fk_film_category_film FOREIGN KEY (film_id) REFERENCES prefix___film (film_id) ON DELETE NO ACTION ON UPDATE CASCADE,
CONSTRAINT prefix___fk_film_category_category FOREIGN KEY (category_id) REFERENCES prefix___category (category_id) ON DELETE NO ACTION ON UPDATE CASCADE
)
;
CREATE INDEX prefix___idx_fk_film_category_film ON prefix___film_category(film_id)
;
CREATE INDEX prefix___idx_fk_film_category_category ON prefix___film_category(category_id)
;
CREATE TRIGGER prefix___film_category_trigger_ai AFTER INSERT ON prefix___film_category
BEGIN
UPDATE prefix___film_category SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___film_category_trigger_au AFTER UPDATE ON prefix___film_category
BEGIN
UPDATE prefix___film_category SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table film_text
--
CREATE TABLE prefix___film_text (
film_id SMALLINT NOT NULL,
title VARCHAR(255) NOT NULL,
description BLOB SUB_TYPE TEXT,
PRIMARY KEY (film_id)
)
;
--
-- Table structure for table inventory
--
CREATE TABLE prefix___inventory (
inventory_id INT NOT NULL,
film_id INT NOT NULL,
store_id INT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (inventory_id),
CONSTRAINT prefix___fk_inventory_store FOREIGN KEY (store_id) REFERENCES prefix___store (store_id) ON DELETE NO ACTION ON UPDATE CASCADE,
CONSTRAINT prefix___fk_inventory_film FOREIGN KEY (film_id) REFERENCES prefix___film (film_id) ON DELETE NO ACTION ON UPDATE CASCADE
)
;
CREATE INDEX prefix___idx_fk_film_id ON prefix___inventory(film_id)
;
CREATE INDEX prefix___idx_fk_film_id_store_id ON prefix___inventory(store_id,film_id)
;
CREATE TRIGGER prefix___inventory_trigger_ai AFTER INSERT ON prefix___inventory
BEGIN
UPDATE prefix___inventory SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___inventory_trigger_au AFTER UPDATE ON prefix___inventory
BEGIN
UPDATE prefix___inventory SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table staff
--
CREATE TABLE prefix___staff (
staff_id SMALLINT NOT NULL,
first_name VARCHAR(45) NOT NULL,
last_name VARCHAR(45) NOT NULL,
address_id INT NOT NULL,
picture BLOB DEFAULT NULL,
email VARCHAR(50) DEFAULT NULL,
store_id INT NOT NULL,
active SMALLINT DEFAULT 1 NOT NULL,
username VARCHAR(16) NOT NULL,
password VARCHAR(40) DEFAULT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (staff_id),
CONSTRAINT prefix___fk_staff_store FOREIGN KEY (store_id) REFERENCES prefix___store (store_id) ON DELETE NO ACTION ON UPDATE CASCADE,
CONSTRAINT prefix___fk_staff_address FOREIGN KEY (address_id) REFERENCES prefix___address (address_id) ON DELETE NO ACTION ON UPDATE CASCADE
)
;
CREATE INDEX prefix___idx_fk_staff_store_id ON prefix___staff(store_id)
;
CREATE INDEX prefix___idx_fk_staff_address_id ON prefix___staff(address_id)
;
CREATE TRIGGER prefix___staff_trigger_ai AFTER INSERT ON prefix___staff
BEGIN
UPDATE prefix___staff SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___staff_trigger_au AFTER UPDATE ON prefix___staff
BEGIN
UPDATE prefix___staff SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table store
--
CREATE TABLE prefix___store (
store_id INT NOT NULL,
manager_staff_id SMALLINT NOT NULL,
address_id INT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (store_id),
CONSTRAINT prefix___fk_store_staff FOREIGN KEY (manager_staff_id) REFERENCES prefix___staff (staff_id) ,
CONSTRAINT prefix___fk_store_address FOREIGN KEY (address_id) REFERENCES prefix___address (address_id)
)
;
CREATE INDEX prefix___idx_store_fk_manager_staff_id ON prefix___store(manager_staff_id)
;
CREATE INDEX prefix___idx_fk_store_address ON prefix___store(address_id)
;
CREATE TRIGGER prefix___store_trigger_ai AFTER INSERT ON prefix___store
BEGIN
UPDATE prefix___store SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___store_trigger_au AFTER UPDATE ON prefix___store
BEGIN
UPDATE prefix___store SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- Table structure for table payment
--
CREATE TABLE prefix___payment (
payment_id int NOT NULL,
customer_id INT NOT NULL,
staff_id SMALLINT NOT NULL,
rental_id INT DEFAULT NULL,
amount DECIMAL(5,2) NOT NULL,
payment_date TIMESTAMP NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (payment_id),
CONSTRAINT prefix___fk_payment_rental FOREIGN KEY (rental_id) REFERENCES prefix___rental (rental_id) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT prefix___fk_payment_customer FOREIGN KEY (customer_id) REFERENCES prefix___customer (customer_id) ,
CONSTRAINT prefix___fk_payment_staff FOREIGN KEY (staff_id) REFERENCES prefix___staff (staff_id)
)
;
CREATE INDEX prefix___idx_fk_staff_id ON prefix___payment(staff_id)
;
CREATE INDEX prefix___idx_fk_customer_id ON prefix___payment(customer_id)
;
CREATE TRIGGER prefix___payment_trigger_ai AFTER INSERT ON prefix___payment
BEGIN
UPDATE prefix___payment SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___payment_trigger_au AFTER UPDATE ON prefix___payment
BEGIN
UPDATE prefix___payment SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TABLE prefix___rental (
rental_id INT NOT NULL,
rental_date TIMESTAMP NOT NULL,
inventory_id INT NOT NULL,
customer_id INT NOT NULL,
return_date TIMESTAMP DEFAULT NULL,
staff_id SMALLINT NOT NULL,
last_update TIMESTAMP NOT NULL,
PRIMARY KEY (rental_id),
CONSTRAINT prefix___fk_rental_staff FOREIGN KEY (staff_id) REFERENCES prefix___staff (staff_id) ,
CONSTRAINT prefix___fk_rental_inventory FOREIGN KEY (inventory_id) REFERENCES prefix___inventory (inventory_id) ,
CONSTRAINT prefix___fk_rental_customer FOREIGN KEY (customer_id) REFERENCES prefix___customer (customer_id)
)
;
CREATE INDEX prefix___idx_rental_fk_inventory_id ON prefix___rental(inventory_id)
;
CREATE INDEX prefix___idx_rental_fk_customer_id ON prefix___rental(customer_id)
;
CREATE INDEX prefix___idx_rental_fk_staff_id ON prefix___rental(staff_id)
;
CREATE UNIQUE INDEX prefix___idx_rental_uq ON prefix___rental (rental_date,inventory_id,customer_id)
;
CREATE TRIGGER prefix___rental_trigger_ai AFTER INSERT ON prefix___rental
BEGIN
UPDATE prefix___rental SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
CREATE TRIGGER prefix___rental_trigger_au AFTER UPDATE ON prefix___rental
BEGIN
UPDATE prefix___rental SET last_update = DATETIME('NOW') WHERE rowid = new.rowid
END
;
--
-- View structure for view customer_list
--
CREATE VIEW prefix___customer_list
AS
SELECT cu.customer_id AS ID,
cu.first_name||' '||cu.last_name AS name,
a.address AS address,
a.postal_code AS zip_code,
a.phone AS phone,
prefix___city.city AS city,
prefix___country.country AS country,
case when cu.active=1 then 'active' else '' end AS notes,
cu.store_id AS SID
FROM prefix___customer AS cu JOIN prefix___address AS a ON cu.address_id = a.address_id JOIN prefix___city ON a.city_id = prefix___city.city_id
JOIN prefix___country ON prefix___city.country_id = prefix___country.country_id
;
--
-- View structure for view film_list
--
CREATE VIEW prefix___film_list
AS
SELECT prefix___film.film_id AS FID,
prefix___film.title AS title,
prefix___film.description AS description,
prefix___category.name AS category,
prefix___film.rental_rate AS price,
prefix___film.length AS length,
prefix___film.rating AS rating,
prefix___actor.first_name||' '||prefix___actor.last_name AS actors
FROM prefix___category LEFT JOIN prefix___film_category ON prefix___category.category_id = prefix___film_category.category_id LEFT JOIN prefix___film ON prefix___film_category.film_id = prefix___film.film_id
JOIN prefix___film_actor ON prefix___film.film_id = prefix___film_actor.film_id
JOIN prefix___actor ON prefix___film_actor.actor_id = prefix___actor.actor_id
;
--
-- View structure for view staff_list
--
CREATE VIEW prefix___staff_list
AS
SELECT s.staff_id AS ID,
s.first_name||' '||s.last_name AS name,
a.address AS address,
a.postal_code AS zip_code,
a.phone AS phone,
prefix___city.city AS city,
prefix___country.country AS country,
s.store_id AS SID
FROM prefix___staff AS s JOIN prefix___address AS a ON s.address_id = a.address_id JOIN prefix___city ON a.city_id = prefix___city.city_id
JOIN prefix___country ON prefix___city.country_id = prefix___country.country_id
;
--
-- View structure for view sales_by_store
--
CREATE VIEW prefix___sales_by_store
AS
SELECT
s.store_id
,c.city||','||cy.country AS store
,m.first_name||' '||m.last_name AS manager
,SUM(p.amount) AS total_sales
FROM prefix___payment AS p
INNER JOIN prefix___rental AS r ON p.rental_id = r.rental_id
INNER JOIN prefix___inventory AS i ON r.inventory_id = i.inventory_id
INNER JOIN prefix___store AS s ON i.store_id = s.store_id
INNER JOIN prefix___address AS a ON s.address_id = a.address_id
INNER JOIN prefix___city AS c ON a.city_id = c.city_id
INNER JOIN prefix___country AS cy ON c.country_id = cy.country_id
INNER JOIN prefix___staff AS m ON s.manager_staff_id = m.staff_id
GROUP BY
s.store_id
, c.city||','||cy.country
, m.first_name||' '||m.last_name
;
--
-- View structure for view sales_by_film_category
--
-- Note that total sales will add up to >100% because
-- some titles belong to more than 1 category
--
CREATE VIEW prefix___sales_by_film_category
AS
SELECT
c.name AS category
, SUM(p.amount) AS total_sales
FROM prefix___payment AS p
INNER JOIN prefix___rental AS r ON p.rental_id = r.rental_id
INNER JOIN prefix___inventory AS i ON r.inventory_id = i.inventory_id
INNER JOIN prefix___film AS f ON i.film_id = f.film_id
INNER JOIN prefix___film_category AS fc ON f.film_id = fc.film_id
INNER JOIN prefix___category AS c ON fc.category_id = c.category_id
GROUP BY c.name
;

231486
packages/nocodb/tests/sqlite-sakila-db/04-sqlite-prefix-sakila-insert-data.sql

File diff suppressed because it is too large Load Diff

1
scripts/cypress/support/commands.js

@ -29,6 +29,7 @@ import { isXcdb, isPostgres } from "./page_objects/projectConstants";
require("@4tw/cypress-drag-drop"); require("@4tw/cypress-drag-drop");
// recursively gets an element, returning only after it's determined to be attached to the DOM for good // recursively gets an element, returning only after it's determined to be attached to the DOM for good
Cypress.Commands.add("getSettled", (selector, opts = {}) => { Cypress.Commands.add("getSettled", (selector, opts = {}) => {
const retries = opts.retries || 3; const retries = opts.retries || 3;

1
scripts/playwright/.env.example

@ -0,0 +1 @@
E2E_DEV_DB_TYPE=sqlite

70
scripts/playwright/.eslintrc.json

@ -0,0 +1,70 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"env": {
"es6": true
},
"ignorePatterns": [
"node_modules",
"build",
"coverage",
"playwright-report",
"output",
"dist",
"nc",
"tsconfig.json",
".eslintrc.json"
],
"plugins": [
"import",
"eslint-comments",
"functional"
],
"extends": [
"eslint:recommended",
"plugin:eslint-comments/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/typescript",
"plugin:prettier/recommended",
"plugin:json/recommended"
],
"globals": {
"BigInt": true,
"console": true,
"WebAssembly": true,
"window": true,
"document": true,
"localStorage": true
},
"rules": {
"@typescript-eslint/no-floating-promises": ["error"],
"@typescript-eslint/explicit-module-boundary-types": "off",
"eslint-comments/disable-enable-pair": [
"error",
{
"allowWholeFile": true
}
],
"eslint-comments/no-unused-disable": "error",
"sort-imports": [
"error",
{
"ignoreDeclarationSort": true,
"ignoreCase": true
}
],
"@typescript-eslint/no-this-alias": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-var-requires": "off",
"no-useless-catch": "off",
"no-empty": "off",
"@typescript-eslint/no-empty-function": "off",
"import/order": "off"
}
}

8
scripts/playwright/.gitignore vendored

@ -0,0 +1,8 @@
node_modules/
/test-results/
/playwright-report/
/playwright-report copy/
/playwright/.cache/
.env
output
/output copy/

3
scripts/playwright/.lintstagedrc.json

@ -0,0 +1,3 @@
{
"**/*.{ts,tsx,js,json}": "npx eslint --fix"
}

2
scripts/playwright/.prettierignore

@ -0,0 +1,2 @@
# package.json is formatted by package managers, so we ignore it here
package.json

7
scripts/playwright/.prettierrc.js

@ -0,0 +1,7 @@
module.exports = {
"trailingComma": "es5",
"arrowParens": "avoid",
singleQuote: true,
tabWidth: 2,
printWidth: 120
};

4
scripts/playwright/constants/index.ts

@ -0,0 +1,4 @@
const airtableApiKey = 'keyn1MR87qgyUsYg4';
const airtableApiBase = 'https://airtable.com/shr4z0qmh6dg5s3eB';
export { airtableApiKey, airtableApiBase };

110
scripts/playwright/fixtures/expectedBaseDownloadData.txt

@ -0,0 +1,110 @@
Country,LastUpdate,City List
Afghanistan,2006-02-15 04:44:00,Kabul
Algeria,2006-02-15 04:44:00,"Batna, Bchar, Skikda"
American Samoa,2006-02-15 04:44:00,Tafuna
Angola,2006-02-15 04:44:00,"Benguela, Namibe"
Anguilla,2006-02-15 04:44:00,South Hill
Argentina,2006-02-15 04:44:00,"Almirante Brown, Avellaneda, Baha Blanca, Crdoba, Escobar, Ezeiza, La Plata, Merlo, Quilmes, San Miguel de Tucumn, Santa F, Tandil, Vicente Lpez"
Armenia,2006-02-15 04:44:00,Yerevan
Australia,2006-02-15 04:44:00,Woodridge
Austria,2006-02-15 04:44:00,"Graz, Linz, Salzburg"
Azerbaijan,2006-02-15 04:44:00,"Baku, Sumqayit"
Bahrain,2006-02-15 04:44:00,al-Manama
Bangladesh,2006-02-15 04:44:00,"Dhaka, Jamalpur, Tangail"
Belarus,2006-02-15 04:44:00,"Mogiljov, Molodetno"
Bolivia,2006-02-15 04:44:00,"El Alto, Sucre"
Brazil,2006-02-15 04:44:00,"Alvorada, Angra dos Reis, Anpolis, Aparecida de Goinia, Araatuba, Bag, Belm, Blumenau, Boa Vista, Braslia, Goinia, Guaruj, guas Lindas de Gois, Ibirit, Juazeiro do Norte, Juiz de Fora, Luzinia, Maring, Po, Poos de Caldas, Rio Claro, Santa Brbara dOeste, Santo Andr, So Bernardo do Campo, So Leopoldo"
Brunei,2006-02-15 04:44:00,Bandar Seri Begawan
Bulgaria,2006-02-15 04:44:00,"Ruse, Stara Zagora"
Cambodia,2006-02-15 04:44:00,"Battambang, Phnom Penh"
Cameroon,2006-02-15 04:44:00,"Bamenda, Yaound"
Canada,2006-02-15 04:44:00,"Gatineau, Halifax, Lethbridge, London, Oshawa, Richmond Hill, Vancouver"
Chad,2006-02-15 04:44:00,NDjamna
Chile,2006-02-15 04:44:00,"Antofagasta, Coquimbo, Rancagua"
China,2006-02-15 04:44:00,"Baicheng, Baiyin, Binzhou, Changzhou, Datong, Daxian, Dongying, Emeishan, Enshi, Ezhou, Fuyu, Fuzhou, Haining, Hami, Hohhot, Huaian, Jinchang, Jining, Jinzhou, Junan, Korla, Laiwu, Laohekou, Lengshuijiang, Leshan"
Colombia,2006-02-15 04:44:00,"Buenaventura, Dos Quebradas, Florencia, Pereira, Sincelejo, Sogamoso"
"Congo, The Democratic Republic of the",2006-02-15 04:44:00,"Lubumbashi, Mwene-Ditu"
Czech Republic,2006-02-15 04:44:00,Olomouc
Dominican Republic,2006-02-15 04:44:00,"La Romana, San Felipe de Puerto Plata, Santiago de los Caballeros"
Ecuador,2006-02-15 04:44:00,"Loja, Portoviejo, Robamba"
Egypt,2006-02-15 04:44:00,"Bilbays, Idfu, Mit Ghamr, Qalyub, Sawhaj, Shubra al-Khayma"
Estonia,2006-02-15 04:44:00,Tartu
Ethiopia,2006-02-15 04:44:00,Addis Abeba
Faroe Islands,2006-02-15 04:44:00,Trshavn
Finland,2006-02-15 04:44:00,Oulu
France,2006-02-15 04:44:00,"Brest, Le Mans, Toulon, Toulouse"
French Guiana,2006-02-15 04:44:00,Cayenne
French Polynesia,2006-02-15 04:44:00,"Faaa, Papeete"
Gambia,2006-02-15 04:44:00,Banjul
Germany,2006-02-15 04:44:00,"Duisburg, Erlangen, Halle/Saale, Mannheim, Saarbrcken, Siegen, Witten"
Greece,2006-02-15 04:44:00,"Athenai, Patras"
Greenland,2006-02-15 04:44:00,Nuuk
Holy See (Vatican City State),2006-02-15 04:44:00,Citt del Vaticano
Hong Kong,2006-02-15 04:44:00,Kowloon and New Kowloon
Hungary,2006-02-15 04:44:00,Szkesfehrvr
India,2006-02-15 04:44:00,"Adoni, Ahmadnagar, Allappuzha (Alleppey), Ambattur, Amroha, Balurghat, Berhampore (Baharampur), Bhavnagar, Bhilwara, Bhimavaram, Bhopal, Bhusawal, Bijapur, Chandrapur, Chapra, Dhule (Dhulia), Etawah, Firozabad, Gandhinagar, Gulbarga, Haldia, Halisahar, Hoshiarpur, Hubli-Dharwad, Jaipur"
Indonesia,2006-02-15 04:44:00,"Cianjur, Ciomas, Ciparay, Gorontalo, Jakarta, Lhokseumawe, Madiun, Pangkal Pinang, Pemalang, Pontianak, Probolinggo, Purwakarta, Surakarta, Tegal"
Iran,2006-02-15 04:44:00,"Arak, Esfahan, Kermanshah, Najafabad, Qomsheh, Shahr-e Kord, Sirjan, Tabriz"
Iraq,2006-02-15 04:44:00,Mosul
Israel,2006-02-15 04:44:00,"Ashdod, Ashqelon, Bat Yam, Tel Aviv-Jaffa"
Italy,2006-02-15 04:44:00,"Alessandria, Bergamo, Brescia, Brindisi, Livorno, Syrakusa, Udine"
Japan,2006-02-15 04:44:00,"Akishima, Fukuyama, Higashiosaka, Hino, Hiroshima, Isesaki, Iwaki, Iwakuni, Iwatsuki, Izumisano, Kakamigahara, Kamakura, Kanazawa, Koriyama, Kurashiki, Kuwana, Matsue, Miyakonojo, Nagareyama, Okayama, Okinawa, Omiya, Onomichi, Otsu, Sagamihara"
Kazakstan,2006-02-15 04:44:00,"Pavlodar, Zhezqazghan"
Kenya,2006-02-15 04:44:00,"Kisumu, Nyeri"
Kuwait,2006-02-15 04:44:00,Jalib al-Shuyukh
Latvia,2006-02-15 04:44:00,"Daugavpils, Liepaja"
Liechtenstein,2006-02-15 04:44:00,Vaduz
Lithuania,2006-02-15 04:44:00,Vilnius
Madagascar,2006-02-15 04:44:00,Mahajanga
Malawi,2006-02-15 04:44:00,Lilongwe
Malaysia,2006-02-15 04:44:00,"Ipoh, Kuching, Sungai Petani"
Mexico,2006-02-15 04:44:00,"Acua, Allende, Atlixco, Carmen, Celaya, Coacalco de Berriozbal, Coatzacoalcos, Cuauhtmoc, Cuautla, Cuernavaca, El Fuerte, Guadalajara, Hidalgo, Huejutla de Reyes, Huixquilucan, Jos Azueta, Jurez, La Paz, Matamoros, Mexicali, Monclova, Nezahualcyotl, Pachuca de Soto, Salamanca, San Felipe del Progreso"
Moldova,2006-02-15 04:44:00,Chisinau
Morocco,2006-02-15 04:44:00,"Beni-Mellal, Nador, Sal"
Mozambique,2006-02-15 04:44:00,"Beira, Naala-Porto, Tete"
Myanmar,2006-02-15 04:44:00,"Monywa, Myingyan"
Nauru,2006-02-15 04:44:00,Yangor
Nepal,2006-02-15 04:44:00,Birgunj
Netherlands,2006-02-15 04:44:00,"Amersfoort, Apeldoorn, Ede, Emmen, s-Hertogenbosch"
New Zealand,2006-02-15 04:44:00,Hamilton
Nigeria,2006-02-15 04:44:00,"Benin City, Deba Habe, Effon-Alaiye, Ife, Ikerre, Ilorin, Kaduna, Ogbomosho, Ondo, Owo, Oyo, Sokoto, Zaria"
North Korea,2006-02-15 04:44:00,Pyongyang
Oman,2006-02-15 04:44:00,"Masqat, Salala"
Pakistan,2006-02-15 04:44:00,"Dadu, Mandi Bahauddin, Mardan, Okara, Shikarpur"
Paraguay,2006-02-15 04:44:00,"Asuncin, Ciudad del Este, San Lorenzo"
Peru,2006-02-15 04:44:00,"Callao, Hunuco, Lima, Sullana"
Philippines,2006-02-15 04:44:00,"Baybay, Bayugan, Bislig, Cabuyao, Cavite, Davao, Gingoog, Hagonoy, Iligan, Imus, Lapu-Lapu, Mandaluyong, Ozamis, Santa Rosa, Taguig, Talavera, Tanauan, Tanza, Tarlac, Tuguegarao"
Poland,2006-02-15 04:44:00,"Bydgoszcz, Czestochowa, Jastrzebie-Zdrj, Kalisz, Lublin, Plock, Tychy, Wroclaw"
Puerto Rico,2006-02-15 04:44:00,"Arecibo, Ponce"
Romania,2006-02-15 04:44:00,"Botosani, Bucuresti"
Runion,2006-02-15 04:44:00,Saint-Denis
Russian Federation,2006-02-15 04:44:00,"Atinsk, Balaiha, Dzerzinsk, Elista, Ivanovo, Jaroslavl, Jelets, Kaliningrad, Kamyin, Kirovo-Tepetsk, Kolpino, Korolev, Kurgan, Kursk, Lipetsk, Ljubertsy, Maikop, Moscow, Nabereznyje Telny, Niznekamsk, Novoterkassk, Pjatigorsk, Serpuhov, Smolensk, Syktyvkar"
Saint Vincent and the Grenadines,2006-02-15 04:44:00,Kingstown
Saudi Arabia,2006-02-15 04:44:00,"Abha, al-Hawiya, al-Qatif, Jedda, Tabuk"
Senegal,2006-02-15 04:44:00,Ziguinchor
Slovakia,2006-02-15 04:44:00,Bratislava
South Africa,2006-02-15 04:44:00,"Boksburg, Botshabelo, Chatsworth, Johannesburg, Kimberley, Klerksdorp, Newcastle, Paarl, Rustenburg, Soshanguve, Springs"
South Korea,2006-02-15 04:44:00,"Cheju, Kimchon, Naju, Tonghae, Uijongbu"
Spain,2006-02-15 04:44:00,"A Corua (La Corua), Donostia-San Sebastin, Gijn, Ourense (Orense), Santiago de Compostela"
Sri Lanka,2006-02-15 04:44:00,Jaffna
Sudan,2006-02-15 04:44:00,"al-Qadarif, Omdurman"
Sweden,2006-02-15 04:44:00,Malm
Switzerland,2006-02-15 04:44:00,"Basel, Bern, Lausanne"
Taiwan,2006-02-15 04:44:00,"Changhwa, Chiayi, Chungho, Fengshan, Hsichuh, Lungtan, Nantou, Tanshui, Touliu, Tsaotun"
Tanzania,2006-02-15 04:44:00,"Mwanza, Tabora, Zanzibar"
Thailand,2006-02-15 04:44:00,"Nakhon Sawan, Pak Kret, Songkhla"
Tonga,2006-02-15 04:44:00,Nukualofa
Tunisia,2006-02-15 04:44:00,Sousse
Turkey,2006-02-15 04:44:00,"Adana, Balikesir, Batman, Denizli, Eskisehir, Gaziantep, Inegl, Kilis, Ktahya, Osmaniye, Sivas, Sultanbeyli, Tarsus, Tokat, Usak"
Turkmenistan,2006-02-15 04:44:00,Ashgabat
Tuvalu,2006-02-15 04:44:00,Funafuti
Ukraine,2006-02-15 04:44:00,"Kamjanets-Podilskyi, Konotop, Mukateve, ostka, Simferopol, Sumy"
United Arab Emirates,2006-02-15 04:44:00,"Abu Dhabi, al-Ayn, Sharja"
United Kingdom,2006-02-15 04:44:00,"Bradford, Dundee, London, Southampton, Southend-on-Sea, Southport, Stockport, York"
United States,2006-02-15 04:44:00,"Akron, Arlington, Augusta-Richmond County, Aurora, Bellevue, Brockton, Cape Coral, Citrus Heights, Clarksville, Compton, Dallas, Dayton, El Monte, Fontana, Garden Grove, Garland, Grand Prairie, Greensboro, Joliet, Kansas City, Lancaster, Laredo, Lincoln, Manchester, Memphis"
Venezuela,2006-02-15 04:44:00,"Barcelona, Caracas, Cuman, Maracabo, Ocumare del Tuy, Valencia, Valle de la Pascua"
Vietnam,2006-02-15 04:44:00,"Cam Ranh, Haiphong, Hanoi, Nam Dinh, Nha Trang, Vinh"
"Virgin Islands, U.S.",2006-02-15 04:44:00,Charlotte Amalie
Yemen,2006-02-15 04:44:00,"Aden, Hodeida, Sanaa, Taizz"
Yugoslavia,2006-02-15 04:44:00,"Kragujevac, Novi Sad"
Zambia,2006-02-15 04:44:00,Kitwe

4
scripts/playwright/fixtures/expectedData.txt

@ -0,0 +1,4 @@
Address,District,PostalCode,Phone,Location,Customer List,Staff List,City,Staff List1
1661 Abha Drive,Tamil Nadu,14400,270456873752,"{""x"":78.8214191,""y"":10.3812871}",1,,Pudukkottai,
1993 Tabuk Lane,Tamil Nadu,64221,648482415405,"{""x"":80.1270701,""y"":12.9246028}",2,,Tambaram,
381 Kabul Way,Taipei,87272,55477302294,"{""x"":0,""y"":0}",2,,Hsichuh,

5
scripts/playwright/fixtures/sampleFiles/1.json

@ -0,0 +1,5 @@
{
"country": "Afghanistan",
"city": ["Kabul"]
}

4
scripts/playwright/fixtures/sampleFiles/2.json

@ -0,0 +1,4 @@
{
"country": "Algeria",
"city": ["Batna", "Bchar", "Skikda"]
}

4
scripts/playwright/fixtures/sampleFiles/3.json

@ -0,0 +1,4 @@
{
"country": "Americal Samoa",
"city": ["Tafuna"]
}

4
scripts/playwright/fixtures/sampleFiles/4.json

@ -0,0 +1,4 @@
{
"country": "Angola",
"city": ["Benguela", "Namibe"]
}

4
scripts/playwright/fixtures/sampleFiles/5.json

@ -0,0 +1,4 @@
{
"country": "Anguilla",
"city": ["South Hill"]
}

4
scripts/playwright/fixtures/sampleFiles/6.json

@ -0,0 +1,4 @@
{
"country": "Argentina",
"city": ["Almirante Brown", "Avellaneda", "Beha Blanca", "Crdoba"]
}

BIN
scripts/playwright/fixtures/sampleFiles/simple.xlsx

Binary file not shown.

22
scripts/playwright/fixtures/template.spec.ts

@ -0,0 +1,22 @@
import { test } from '@playwright/test';
import { DashboardPage } from '../pages/Dashboard';
import setup from '../setup';
import { ToolbarPage } from '../pages/Dashboard/common/Toolbar';
test.describe.only('Test block name', () => {
let dashboard: DashboardPage;
let toolbar: ToolbarPage;
let context: any;
test.beforeEach(async ({ page }) => {
context = await setup({ page });
dashboard = new DashboardPage(page, context.project);
toolbar = dashboard.grid.toolbar;
});
test('Test case name', async () => {
// close 'Team & Auth' tab
await dashboard.closeTab({ title: 'Team & Auth' });
await dashboard.treeView.openTable({ title: 'Country' });
});
});

8336
scripts/playwright/package-lock.json generated

File diff suppressed because it is too large Load Diff

41
scripts/playwright/package.json

@ -0,0 +1,41 @@
{
"name": "playwright",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "TRACE=true npx playwright test --workers=4",
"test:repeat": "TRACE=true npx playwright test --workers=4 --repeat-each=10",
"test:quick": "TRACE=true PW_QUICK_TEST=1 npx playwright test --workers=4",
"test:debug": "./startPlayWrightServer.sh; PW_TEST_REUSE_CONTEXT=1 PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:31000/ PWDEBUG=console npx playwright test -c playwright.config.ts --headed --project=chromium --retries 0 --timeout 0 --workers 1 --max-failures=1",
"test:debug:quick:sqlite": "./startPlayWrightServer.sh; PW_QUICK_TEST=1 PW_TEST_REUSE_CONTEXT=1 PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:31000/ PWDEBUG=console npx playwright test -c playwright.config.ts --headed --project=chromium --retries 0 --timeout 5 --workers 1 --max-failures=1",
"ci:test:mysql": "E2E_DB_TYPE=mysql npx playwright test --workers=2"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.27.1",
"@typescript-eslint/eslint-plugin": "^4.0.1",
"@typescript-eslint/parser": "^4.0.1",
"axios": "^0.24.0",
"dotenv": "^16.0.3",
"eslint": "^7.8.0",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-functional": "^3.0.2",
"eslint-plugin-import": "^2.22.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-json": "^3.1.0",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"mysql2": "^2.3.3",
"prettier": "^2.7.1",
"promised-sqlite3": "^1.2.0"
},
"dependencies": {
"body-parser": "^1.20.1",
"express": "^4.18.2",
"xlsx": "^0.18.5"
}
}

81
scripts/playwright/pages/Base.ts

@ -0,0 +1,81 @@
import { Locator, Page } from '@playwright/test';
type ResponseSelector = (json: any) => boolean;
export default abstract class BasePage {
readonly rootPage: Page;
abstract get(args?: any): Locator;
constructor(rootPage: Page) {
this.rootPage = rootPage;
}
async verifyToast({ message }: { message: string }) {
await this.rootPage.locator('.ant-message .ant-message-notice-content', { hasText: message }).last().isVisible();
}
async waitForResponse({
uiAction,
httpMethodsToMatch = [],
requestUrlPathToMatch,
responseJsonMatcher,
}: {
uiAction: Promise<any>;
requestUrlPathToMatch: string;
httpMethodsToMatch?: string[];
responseJsonMatcher?: ResponseSelector;
}) {
await Promise.all([
this.rootPage.waitForResponse(async res => {
let isResJsonMatched = true;
if (responseJsonMatcher) {
try {
isResJsonMatched = responseJsonMatcher(await res.json());
} catch (e) {
return false;
}
}
return (
res.request().url().includes(requestUrlPathToMatch) &&
httpMethodsToMatch.includes(res.request().method()) &&
isResJsonMatched
);
}),
uiAction,
]);
}
async attachFile({ filePickUIAction, filePath }: { filePickUIAction: Promise<any>; filePath: string }) {
const [fileChooser] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting.
this.rootPage.waitForEvent('filechooser'),
// Opens the file chooser.
filePickUIAction,
]);
await fileChooser.setFiles(filePath);
}
async downloadAndGetFile({ downloadUIAction }: { downloadUIAction: Promise<any> }) {
const [download] = await Promise.all([
// It is important to call waitForEvent before click to set up waiting.
this.rootPage.waitForEvent('download'),
// Triggers the download.
downloadUIAction,
]);
// wait for download to complete
if (await download.failure()) {
throw new Error('Download failed');
}
const file = await download.createReadStream();
const data = await new Promise((resolve, reject) => {
let data = '';
file?.on('data', chunk => (data += chunk));
file?.on('end', () => resolve(data));
file?.on('error', reject);
});
return data as any;
}
}

112
scripts/playwright/pages/Dashboard/ExpandedForm/index.ts

@ -0,0 +1,112 @@
import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { DashboardPage } from '..';
export class ExpandedFormPage extends BasePage {
readonly dashboard: DashboardPage;
readonly addNewTableButton: Locator;
readonly copyUrlButton: Locator;
readonly toggleCommentsButton: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.addNewTableButton = this.dashboard.get().locator('.nc-add-new-table');
this.copyUrlButton = this.dashboard.get().locator('.nc-copy-row-url:visible');
this.toggleCommentsButton = this.dashboard.get().locator('.nc-toggle-comments:visible');
}
get() {
return this.dashboard.get().locator(`.nc-drawer-expanded-form`);
}
async gotoUsingUrlAndRowId({ rowId }: { rowId: string }) {
const url = await this.dashboard.rootPage.url();
const expandedFormUrl = '/' + url.split('/').slice(3).join('/').split('?')[0] + `?rowId=${rowId}`;
await this.rootPage.goto(expandedFormUrl);
await this.dashboard.waitForLoaderToDisappear();
}
async fillField({ columnTitle, value, type = 'text' }: { columnTitle: string; value: string; type?: string }) {
const field = this.get().locator(`[data-nc="nc-expand-col-${columnTitle}"]`);
await field.hover();
switch (type) {
case 'text':
await field.locator('input').fill(value);
break;
case 'belongsTo':
await field.locator('.nc-action-icon').click();
await this.dashboard.linkRecord.select(value);
break;
case 'hasMany':
case 'manyToMany':
await field.locator(`[data-cy="nc-child-list-button-link-to"]`).click();
await this.dashboard.linkRecord.select(value);
break;
}
}
async save({
waitForRowsData = true,
}: {
waitForRowsData?: boolean;
} = {}) {
const saveRowAction = this.get().locator('button:has-text("Save Row")').click();
if (waitForRowsData) {
await this.waitForResponse({
uiAction: saveRowAction,
requestUrlPathToMatch: 'api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
responseJsonMatcher: json => json['pageInfo'],
});
} else {
await this.waitForResponse({
uiAction: saveRowAction,
requestUrlPathToMatch: 'api/v1/db/data/noco/',
httpMethodsToMatch: ['POST'],
});
}
await this.get().press('Escape');
await this.get().waitFor({ state: 'hidden' });
await this.verifyToast({ message: `updated successfully.` });
await this.rootPage.locator('[data-nc="grid-load-spinner"]').waitFor({ state: 'hidden' });
}
async verify({ header, url }: { header: string; url: string }) {
await expect(this.get().locator(`.nc-expanded-form-header`).last()).toContainText(header);
await expect.poll(() => this.rootPage.url()).toContain(url);
}
async close() {
await this.rootPage.keyboard.press('Escape');
}
async cancel() {
await this.get().locator('button:has-text("Cancel")').last().click();
}
async openChildCard(param: { column: string; title: string }) {
const childList = await this.get().locator(`[data-nc="nc-expand-col-${param.column}"]`);
await childList.locator(`.ant-card:has-text("${param.title}")`).click();
}
async verifyCount({ count }: { count: number }) {
return await expect(this.rootPage.locator(`.nc-drawer-expanded-form .ant-drawer-content`)).toHaveCount(count);
}
async validateRoleAccess(param: { role: string }) {
if (param.role === 'commenter' || param.role === 'viewer') {
await expect(await this.get().locator('button:has-text("Save Row")')).toBeDisabled();
} else {
await expect(await this.get().locator('button:has-text("Save Row")')).toBeEnabled();
}
if (param.role === 'viewer') {
await expect(await this.toggleCommentsButton).toHaveCount(0);
} else {
await expect(await this.toggleCommentsButton).toHaveCount(1);
}
// press escape to close the expanded form
await this.rootPage.keyboard.press('Escape');
}
}

252
scripts/playwright/pages/Dashboard/Form/index.ts

@ -0,0 +1,252 @@
import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { ToolbarPage } from '../common/Toolbar';
export class FormPage extends BasePage {
readonly dashboard: DashboardPage;
readonly toolbar: ToolbarPage;
readonly addAllButton: Locator;
readonly removeAllButton: Locator;
readonly submitButton: Locator;
readonly showAnotherFormRadioButton: Locator;
readonly showAnotherFormAfter5SecRadioButton: Locator;
readonly emailMeRadioButton: Locator;
readonly formHeading: Locator;
readonly formSubHeading: Locator;
readonly afterSubmitMsg: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.toolbar = new ToolbarPage(this);
this.addAllButton = dashboard.get().locator('[data-nc="nc-form-add-all"]');
this.removeAllButton = dashboard.get().locator('[data-nc="nc-form-remove-all"]');
this.submitButton = dashboard.get().locator('[data-nc="nc-form-submit"]');
this.showAnotherFormRadioButton = dashboard.get().locator('[data-nc="nc-form-checkbox-submit-another-form"]');
this.showAnotherFormAfter5SecRadioButton = dashboard.get().locator('[data-nc="nc-form-checkbox-show-blank-form"]');
this.emailMeRadioButton = dashboard.get().locator('[data-nc="nc-form-checkbox-send-email"]');
this.formHeading = dashboard.get().locator('[data-nc="nc-form-heading"]');
this.formSubHeading = dashboard.get().locator('[data-nc="nc-form-sub-heading"]');
this.afterSubmitMsg = dashboard.get().locator('[data-nc="nc-form-after-submit-msg"]');
}
get() {
return this.dashboard.get().locator('[data-nc="nc-form-wrapper"]');
}
getFormAfterSubmit() {
return this.dashboard.get().locator('[data-nc="nc-form-wrapper-submit"]');
}
getFormHiddenColumn() {
return this.get().locator('[data-nc="nc-form-hidden-column"]');
}
getFormFields() {
return this.get().locator('[data-nc="nc-form-fields"]');
}
getDragNDropToHide() {
return this.get().locator('[data-nc="nc-drag-n-drop-to-hide"]');
}
getFormFieldsRemoveIcon() {
return this.get().locator('[data-nc="nc-field-remove-icon"]');
}
getFormFieldsRequired() {
return this.get().locator('[data-nc="nc-form-input-required"]');
}
getFormFieldsInputLabel() {
return this.get().locator('input[data-nc="nc-form-input-label"]:visible');
}
getFormFieldsInputHelpText() {
return this.get().locator('input[data-nc="nc-form-input-help-text"]:visible');
}
async verifyFormFieldLabel({ index, label }: { index: number; label: string }) {
await expect(await this.getFormFields().nth(index).locator('[data-nc="nc-form-input-label"]')).toContainText(label);
}
async verifyFormFieldHelpText({ index, helpText }: { index: number; helpText: string }) {
await expect(
await this.getFormFields().nth(index).locator('[data-nc="nc-form-input-help-text-label"]')
).toContainText(helpText);
}
async verifyFieldsIsEditable({ index }: { index: number }) {
await expect(await this.getFormFields().nth(index)).toHaveClass(/nc-editable/);
}
async verifyAfterSubmitMsg({ msg }: { msg: string }) {
await expect((await this.afterSubmitMsg.inputValue()).includes(msg)).toBeTruthy();
}
async verifyFormViewFieldsOrder({ fields }: { fields: string[] }) {
const fieldLabels = await this.get().locator('[data-nc="nc-form-input-label"]');
await expect(await fieldLabels).toHaveCount(fields.length);
for (let i = 0; i < fields.length; i++) {
await expect(await fieldLabels.nth(i)).toContainText(fields[i]);
}
}
async reorderFields({ sourceField, destinationField }: { sourceField: string; destinationField: string }) {
await expect(await this.get().locator(`.nc-form-drag-${sourceField}`)).toBeVisible();
await expect(await this.get().locator(`.nc-form-drag-${destinationField}`)).toBeVisible();
const src = await this.get().locator(`.nc-form-drag-${sourceField.replace(' ', '')}`);
const dst = await this.get().locator(`.nc-form-drag-${destinationField.replace(' ', '')}`);
await src.dragTo(dst);
}
async removeField({ field, mode }: { mode: string; field: string }) {
if (mode === 'dragDrop') {
const src = await this.get().locator(`.nc-form-drag-${field.replace(' ', '')}`);
const dst = await this.get().locator(`[data-nc="nc-drag-n-drop-to-hide"]`);
await src.dragTo(dst);
} else if (mode === 'hideField') {
const src = await this.get().locator(`.nc-form-drag-${field.replace(' ', '')}`);
await src.locator(`[data-nc="nc-field-remove-icon"]`).click();
}
}
async addField({ field, mode }: { mode: string; field: string }) {
if (mode === 'dragDrop') {
const src = await this.get().locator(`[data-nc="nc-form-hidden-column-${field}"]`);
const dst = await this.get().locator(`.nc-form-drag-Country`);
await src.dragTo(dst);
} else if (mode === 'clickField') {
const src = await this.get().locator(`[data-nc="nc-form-hidden-column-${field}"]`);
await src.click();
}
}
async removeAllFields() {
await this.removeAllButton.click();
}
async addAllFields() {
await this.addAllButton.click();
}
async configureHeader(param: { subtitle: string; title: string }) {
await this.formHeading.fill(param.title);
await this.formSubHeading.fill(param.subtitle);
}
async verifyHeader(param: { subtitle: string; title: string }) {
await expect.poll(async () => await this.formHeading.inputValue()).toBe(param.title);
await expect.poll(async () => await this.formSubHeading.inputValue()).toBe(param.subtitle);
}
async fillForm(param: { field: string; value: string }[]) {
for (let i = 0; i < param.length; i++) {
await this.get()
.locator(`[data-nc="nc-form-input-${param[i].field.replace(' ', '')}"] >> input`)
.fill(param[i].value);
}
}
async configureField({
field,
required,
label,
helpText,
}: {
field: string;
required: boolean;
label: string;
helpText: string;
}) {
await this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('div[data-nc="nc-form-input-label"]')
.click();
await this.getFormFieldsInputLabel().fill(label);
await this.getFormFieldsInputHelpText().fill(helpText);
if (required) {
await this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.click();
}
await this.formHeading.click();
}
async verifyField({
field,
required,
label,
helpText,
}: {
field: string;
required: boolean;
label: string;
helpText: string;
}) {
let expectText = '';
if (required) expectText = label + ' *';
else expectText = label;
const fieldLabel = await this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('div[data-nc="nc-form-input-label"]');
await expect(fieldLabel).toHaveText(expectText);
const fieldHelpText = await this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('div[data-nc="nc-form-input-help-text-label"]');
await expect(fieldHelpText).toHaveText(helpText);
}
async submitForm() {
await this.submitButton.click();
}
async verifyStatePostSubmit(param: { message?: string; submitAnotherForm?: boolean; showBlankForm?: boolean }) {
if (undefined !== param.message) {
await expect(await this.getFormAfterSubmit()).toContainText(param.message);
}
if (true === param.submitAnotherForm) {
await expect(await this.getFormAfterSubmit().locator('button:has-text("Submit Another Form")')).toBeVisible();
}
if (true === param.showBlankForm) {
await this.get().waitFor();
}
}
async configureSubmitMessage(param: { message: string }) {
await this.afterSubmitMsg.fill(param.message);
}
submitAnotherForm() {
return this.getFormAfterSubmit().locator('button:has-text("Submit Another Form")');
}
// todo: Wait for render to complete
async waitLoading() {
await this.rootPage.waitForTimeout(1000);
}
async verifyAfterSubmitMenuState(param: { showBlankForm?: boolean; submitAnotherForm?: boolean; emailMe?: boolean }) {
if (true === param.showBlankForm) {
await expect(
this.get().locator('[data-nc="nc-form-checkbox-show-blank-form"][aria-checked="true"]')
).toBeVisible();
}
if (true === param.submitAnotherForm) {
await expect(
this.get().locator('[data-nc="nc-form-checkbox-submit-another-form"][aria-checked="true"]')
).toBeVisible();
}
if (true === param.emailMe) {
await expect(this.get().locator('[data-nc="nc-form-checkbox-send-email"][aria-checked="true"]')).toBeVisible();
}
}
}

32
scripts/playwright/pages/Dashboard/Gallery/index.ts

@ -0,0 +1,32 @@
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { ToolbarPage } from '../common/Toolbar';
export class GalleryPage extends BasePage {
readonly dashboard: DashboardPage;
readonly toolbar: ToolbarPage;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.toolbar = new ToolbarPage(this);
}
get() {
return this.dashboard.get().locator('[data-nc="nc-gallery-wrapper"]');
}
card(index: number) {
return this.get().locator(`.ant-card`).nth(index);
}
async openExpandedRow({ index }: { index: number }) {
await this.card(index).click();
await (await this.rootPage.locator('.ant-drawer-body').elementHandle())?.waitForElementState('stable');
}
// todo: Wait for render to complete
async waitLoading() {
await this.rootPage.waitForTimeout(1000);
}
}

56
scripts/playwright/pages/Dashboard/Grid/Column/LTAR/ChildList.ts

@ -0,0 +1,56 @@
import BasePage from '../../../../Base';
import { DashboardPage } from '../../../index';
import { expect } from '@playwright/test';
export class ChildList extends BasePage {
readonly dashboard: DashboardPage;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
}
get() {
return this.dashboard.get().locator(`.nc-modal-child-list`);
}
async verify({ cardTitle, linkField }: { cardTitle: string[]; linkField: string }) {
// DOM element validation
// title: Child list
// button: Link to 'City'
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Child list`);
await expect(await this.get().locator(`button:has-text("Link to '${linkField}'")`).isVisible()).toBeTruthy();
await expect(await this.get().locator(`[data-cy="nc-child-list-reload"]`).isVisible()).toBeTruthy();
// child list body validation (card count, card title)
const cardCount = cardTitle.length;
await this.get().locator('.ant-modal-content').waitFor();
{
const childList = this.get().locator(`.ant-card`);
const childCards = await childList.count();
await expect(childCards).toEqual(cardCount);
for (let i = 0; i < cardCount; i++) {
await expect(await childList.nth(i).textContent()).toContain(cardTitle[i]);
// icon: unlink
// icon: delete
await expect(await childList.nth(i).locator(`[data-cy="nc-child-list-icon-unlink"]`).isVisible()).toBeTruthy();
await expect(await childList.nth(i).locator(`[data-cy="nc-child-list-icon-delete"]`).isVisible()).toBeTruthy();
}
}
}
async close() {
await this.get().locator(`.ant-modal-close-x`).click();
await this.get().waitFor({ state: 'hidden' });
}
async openLinkRecord({ linkTableTitle }: { linkTableTitle: string }) {
const openActions = this.get().locator(`button:has-text("Link to '${linkTableTitle}'")`).click();
await this.waitForResponse({
requestUrlPathToMatch: '/exclude',
httpMethodsToMatch: ['GET'],
uiAction: openActions,
});
}
}

49
scripts/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts

@ -0,0 +1,49 @@
import BasePage from '../../../../Base';
import { DashboardPage } from '../../../index';
import { expect } from '@playwright/test';
export class LinkRecord extends BasePage {
readonly dashboard: DashboardPage;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
}
async verify(cardTitle?: string[]) {
await this.dashboard.get().locator('.nc-modal-link-record').waitFor();
const linkRecord = await this.get();
// DOM element validation
// title: Link Record
// button: Add new record
// icon: reload
await expect(this.get().locator(`.ant-modal-title`)).toHaveText(`Link record`);
await expect(await linkRecord.locator(`button:has-text("Add new record")`).isVisible()).toBeTruthy();
await expect(await linkRecord.locator(`.nc-reload`).isVisible()).toBeTruthy();
// placeholder: Filter query
await expect(await linkRecord.locator(`[placeholder="Filter query"]`).isVisible()).toBeTruthy();
{
const childList = linkRecord.locator(`.ant-card`);
const childCards = await childList.count();
await expect(childCards).toEqual(cardTitle.length);
for (let i = 0; i < cardTitle.length; i++) {
await expect(await childList.nth(i).textContent()).toContain(cardTitle[i]);
}
}
}
async select(cardTitle: string) {
await this.get().locator(`.ant-card:has-text("${cardTitle}"):visible`).click();
}
async close() {
await this.get().locator(`.ant-modal-close-x`).click();
await this.get().waitFor({ state: 'hidden' });
}
get() {
return this.dashboard.get().locator(`.nc-modal-link-record`);
}
}

78
scripts/playwright/pages/Dashboard/Grid/Column/SelectOptionColumn.ts

@ -0,0 +1,78 @@
import { ColumnPageObject } from '.';
import BasePage from '../../../Base';
export class SelectOptionColumnPageObject extends BasePage {
readonly column: ColumnPageObject;
constructor(column: ColumnPageObject) {
super(column.rootPage);
this.column = column;
}
get() {
return this.column.get();
}
async addOption({
index,
columnTitle,
option,
skipColumnModal,
}: {
index: number;
option: string;
skipColumnModal?: boolean;
columnTitle?: string;
}) {
if (!skipColumnModal && columnTitle) await this.column.openEdit({ title: columnTitle });
await this.column.get().locator('button:has-text("Add option")').click();
// Fill text=Select options can't be nullAdd option >> input[type="text"]
await this.column.get().locator(`[data-nc="select-column-option-input-${index}"]`).click();
await this.column.get().locator(`[data-nc="select-column-option-input-${index}"]`).fill(option);
if (!skipColumnModal && columnTitle) await this.column.save({ isUpdated: true });
}
async editOption({ columnTitle, index, newOption }: { index: number; columnTitle: string; newOption: string }) {
await this.column.openEdit({ title: columnTitle });
await this.column.get().locator(`[data-nc="select-column-option-input-${index}"]`).click();
await this.column.get().locator(`[data-nc="select-column-option-input-${index}"]`).fill(newOption);
await this.column.save({ isUpdated: true });
}
async deleteOption({ columnTitle, index }: { index: number; columnTitle: string }) {
await this.column.openEdit({ title: columnTitle });
await this.column.get().locator(`svg[data-nc="select-column-option-remove-${index}"]`).click();
await this.column.save({ isUpdated: true });
}
async reorderOption({
columnTitle,
sourceOption,
destinationOption,
}: {
columnTitle: string;
sourceOption: string;
destinationOption: string;
}) {
await this.column.openEdit({ title: columnTitle });
await this.column.rootPage.waitForTimeout(150);
await this.column.rootPage.dragAndDrop(
`svg[data-nc="select-option-column-handle-icon-${sourceOption}"]`,
`svg[data-nc="select-option-column-handle-icon-${destinationOption}"]`,
{
force: true,
}
);
await this.column.save({ isUpdated: true });
}
}

219
scripts/playwright/pages/Dashboard/Grid/Column/index.ts

@ -0,0 +1,219 @@
import { expect, Page } from '@playwright/test';
import { GridPage } from '..';
import BasePage from '../../../Base';
import { SelectOptionColumnPageObject } from './SelectOptionColumn';
export class ColumnPageObject extends BasePage {
readonly grid: GridPage;
readonly selectOption: SelectOptionColumnPageObject;
constructor(grid: GridPage) {
super(grid.rootPage);
this.grid = grid;
this.selectOption = new SelectOptionColumnPageObject(this);
}
get() {
return this.rootPage.locator('form[data-nc="add-or-edit-column"]');
}
async create({
title,
type = 'SingleLineText',
formula = '',
childTable = '',
childColumn = '',
relationType = '',
rollupType = '',
format = '',
}: {
title: string;
type?: string;
formula?: string;
childTable?: string;
childColumn?: string;
relationType?: string;
rollupType?: string;
format?: string;
}) {
await this.grid.get().locator('.nc-column-add').click();
await this.rootPage.waitForTimeout(500);
await this.fillTitle({ title });
await this.rootPage.waitForTimeout(500);
await this.selectType({ type });
await this.rootPage.waitForTimeout(500);
switch (type) {
case 'SingleTextLine':
break;
case 'SingleSelect':
case 'MultiSelect':
await this.selectOption.addOption({
index: 0,
option: 'Option 1',
skipColumnModal: true,
});
await this.selectOption.addOption({
index: 1,
option: 'Option 2',
skipColumnModal: true,
});
break;
case 'Duration':
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: format,
})
.click();
break;
case 'Formula':
await this.get().locator('.nc-formula-input').fill(formula);
break;
case 'Lookup':
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: childTable,
})
.click();
await this.get().locator('.ant-select-single').nth(2).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: childColumn,
})
.click();
break;
case 'Rollup':
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: childTable,
})
.click();
await this.get().locator('.ant-select-single').nth(2).click();
await this.rootPage
.locator(`.nc-dropdown-relation-column >> .ant-select-item`, {
hasText: childColumn,
})
.click();
await this.get().locator('.ant-select-single').nth(3).click();
await this.rootPage
.locator(`.nc-dropdown-rollup-function >> .ant-select-item`, {
hasText: rollupType,
})
.nth(0)
.click();
break;
case 'LinkToAnotherRecord':
await this.get()
.locator('.nc-ltar-relation-type >> .ant-radio')
.nth(relationType === 'Has Many' ? 0 : 1)
.click();
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).fill(childTable);
await this.rootPage
.locator(`.nc-dropdown-ltar-child-table >> .ant-select-item`, {
hasText: childTable,
})
.nth(0)
.click();
break;
default:
break;
}
await this.save();
}
async fillTitle({ title }: { title: string }) {
await this.get().locator('.nc-column-name-input').fill(title);
}
async selectType({ type }: { type: string }) {
await this.get().locator('.ant-select-selector > .ant-select-selection-item').click();
await this.get().locator('.ant-select-selection-search-input[aria-expanded="true"]').waitFor();
await this.get().locator('.ant-select-selection-search-input[aria-expanded="true"]').fill(type);
// Select column type
await this.rootPage.locator(`text=${type}`).nth(1).click();
}
async delete({ title }: { title: string }) {
await this.grid.get().locator(`th[data-title="${title}"] >> svg.ant-dropdown-trigger`).click();
// await this.rootPage.locator('li[role="menuitem"]:has-text("Delete")').waitFor();
await this.rootPage.locator('li[role="menuitem"]:has-text("Delete")').click();
await this.rootPage.locator('button:has-text("Delete")').click();
// wait till modal is closed
await this.rootPage.locator('.nc-modal-column-delete').waitFor({ state: 'hidden' });
}
async openEdit({
title,
type = 'SingleLineText',
formula = '',
format,
}: {
title: string;
type?: string;
formula?: string;
format?: string;
}) {
await this.grid.get().locator(`th[data-title="${title}"] .nc-ui-dt-dropdown`).click();
await this.rootPage.locator('li[role="menuitem"]:has-text("Edit")').click();
await this.get().waitFor({ state: 'visible' });
switch (type) {
case 'Formula':
await this.get().locator('.nc-formula-input').fill(formula);
break;
case 'Duration':
await this.get().locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: format,
})
.click();
break;
default:
break;
}
}
async save({ isUpdated }: { isUpdated?: boolean } = {}) {
await this.waitForResponse({
uiAction: this.get().locator('button:has-text("Save")').click(),
requestUrlPathToMatch: 'api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
responseJsonMatcher: json => json['pageInfo'],
});
await this.verifyToast({
message: isUpdated ? 'Column updated' : 'Column created',
});
await this.get().waitFor({ state: 'hidden' });
await this.rootPage.waitForTimeout(200);
}
async verify({ title, isVisible = true }: { title: string; isVisible?: boolean }) {
if (!isVisible) {
return await expect(await this.rootPage.locator(`th[data-title="${title}"]`)).not.toBeVisible();
}
await await expect(this.rootPage.locator(`th[data-title="${title}"]`)).toContainText(title);
}
async verifyRoleAccess(param: { role: string }) {
await expect(this.grid.get().locator('.nc-column-add:visible')).toHaveCount(param.role === 'creator' ? 1 : 0);
await expect(this.grid.get().locator('.nc-ui-dt-dropdown:visible')).toHaveCount(param.role === 'creator' ? 3 : 0);
if (param.role === 'creator') {
await this.grid.get().locator('.nc-ui-dt-dropdown:visible').first().click();
await expect(this.rootPage.locator('.nc-dropdown-column-operations')).toHaveCount(1);
await this.grid.get().locator('.nc-ui-dt-dropdown:visible').first().click();
}
}
}

280
scripts/playwright/pages/Dashboard/Grid/index.ts

@ -0,0 +1,280 @@
import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { CellPageObject } from '../common/Cell';
import { ColumnPageObject } from './Column';
import { ToolbarPage } from '../common/Toolbar';
import { ProjectMenuObject } from '../common/ProjectMenu';
export class GridPage extends BasePage {
readonly dashboard: DashboardPage;
readonly addNewTableButton: Locator;
readonly dashboardPage: DashboardPage;
readonly column: ColumnPageObject;
readonly cell: CellPageObject;
readonly toolbar: ToolbarPage;
readonly projectMenu: ProjectMenuObject;
constructor(dashboardPage: DashboardPage) {
super(dashboardPage.rootPage);
this.dashboard = dashboardPage;
this.addNewTableButton = dashboardPage.get().locator('.nc-add-new-table');
this.column = new ColumnPageObject(this);
this.cell = new CellPageObject(this);
this.toolbar = new ToolbarPage(this);
this.projectMenu = new ProjectMenuObject(this);
}
get() {
return this.dashboard.get().locator('[data-nc="nc-grid-wrapper"]');
}
row(index: number) {
return this.get().locator(`tr[data-nc="grid-row-${index}"]`);
}
async rowCount() {
return await this.get().locator('.nc-grid-row').count();
}
async verifyRowCount({ count }: { count: number }) {
return await expect(this.get().locator('.nc-grid-row')).toHaveCount(count);
}
private async _fillRow({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const cell = this.cell.get({ index, columnHeader });
await this.cell.dblclick({
index,
columnHeader,
});
await cell.locator('input').fill(value);
}
async addNewRow({
index = 0,
columnHeader = 'Title',
value,
networkValidation = true,
}: {
index?: number;
columnHeader?: string;
value?: string;
networkValidation?: boolean;
} = {}) {
const rowValue = value ?? `Row ${index}`;
const rowCount = await this.get().locator('.nc-grid-row').count();
await this.get().locator('.nc-grid-add-new-cell').click();
await expect(this.get().locator('.nc-grid-row')).toHaveCount(rowCount + 1);
await this._fillRow({ index, columnHeader, value: rowValue });
const clickOnColumnHeaderToSave = this.get()
.locator(`[data-title="${columnHeader}"]`)
.locator(`span[title="${columnHeader}"]`)
.click();
if (networkValidation) {
await this.waitForResponse({
uiAction: clickOnColumnHeaderToSave,
requestUrlPathToMatch: 'api/v1/db/data/noco',
httpMethodsToMatch: ['POST'],
responseJsonMatcher: resJson => resJson?.[columnHeader] === value,
});
} else {
await this.rootPage.waitForTimeout(300);
}
await this.dashboard.waitForLoaderToDisappear();
}
async editRow({
index = 0,
columnHeader = 'Title',
value,
networkValidation = true,
}: {
index?: number;
columnHeader?: string;
value: string;
networkValidation?: boolean;
}) {
await this._fillRow({ index, columnHeader, value });
const clickOnColumnHeaderToSave = this.get()
.locator(`[data-title="${columnHeader}"]`)
.locator(`span[title="${columnHeader}"]`)
.click();
if (networkValidation) {
await this.waitForResponse({
uiAction: clickOnColumnHeaderToSave,
requestUrlPathToMatch: 'api/v1/db/data/noco',
httpMethodsToMatch: ['PATCH'],
responseJsonMatcher: resJson => resJson?.[columnHeader] === value,
});
} else {
await this.rootPage.waitForTimeout(300);
}
await this.dashboard.waitForLoaderToDisappear();
}
async verifyRow({ index }: { index: number }) {
await this.get().locator(`td[data-nc="cell-Title-${index}"]`).waitFor({ state: 'visible' });
await expect(this.get().locator(`td[data-nc="cell-Title-${index}"]`)).toHaveCount(1);
}
async verifyRowDoesNotExist({ index }: { index: number }) {
await this.get().locator(`td[data-nc="cell-Title-${index}"]`).waitFor({ state: 'hidden' });
return await expect(this.get().locator(`td[data-nc="cell-Title-${index}"]`)).toHaveCount(0);
}
async deleteRow(index: number) {
await this.get().locator(`td[data-nc="cell-Title-${index}"]`).click({
button: 'right',
});
// Click text=Delete Row
await this.rootPage.locator('text=Delete Row').click();
// todo: improve selector
await this.rootPage
.locator('span.ant-dropdown-menu-title-content > nc-project-menu-item')
.waitFor({ state: 'hidden' });
await this.rootPage.waitForTimeout(300);
await this.dashboard.waitForLoaderToDisappear();
}
async addRowRightClickMenu(index: number) {
const rowCount = await this.get().locator('.nc-grid-row').count();
await this.get().locator(`td[data-nc="cell-Title-${index}"]`).click({
button: 'right',
});
// Click text=Insert New Row
await this.rootPage.locator('text=Insert New Row').click();
await expect(await this.get().locator('.nc-grid-row')).toHaveCount(rowCount + 1);
}
async openExpandedRow({ index }: { index: number }) {
await this.row(index).locator(`td[data-nc="cell-Id-${index}"]`).hover();
await this.row(index).locator(`div[data-nc="nc-expand-${index}"]`).click();
await (await this.rootPage.locator('.ant-drawer-body').elementHandle())?.waitForElementState('stable');
}
async selectAll() {
await this.get().locator('[data-nc="nc-check-all"]').hover();
await this.get().locator('[data-nc="nc-check-all"]').locator('input[type="checkbox"]').check({
force: true,
});
const rowCount = await this.rowCount();
for (let i = 0; i < rowCount; i++) {
await expect(this.row(i).locator(`[data-nc="cell-Id-${i}"]`).locator('span.ant-checkbox-checked')).toHaveCount(1);
}
await this.rootPage.waitForTimeout(300);
}
async deleteAll() {
await this.selectAll();
await this.get().locator('[data-nc="nc-check-all"]').nth(0).click({
button: 'right',
});
await this.rootPage.locator('text=Delete Selected Rows').click();
await this.dashboard.waitForLoaderToDisappear();
}
private async pagination({ page }: { page: string }) {
await this.get().locator(`.nc-pagination`).waitFor();
if (page === '<') return this.get().locator('.nc-pagination > .ant-pagination-prev');
if (page === '>') return this.get().locator('.nc-pagination > .ant-pagination-next');
return this.get().locator(`.nc-pagination > .ant-pagination-item.ant-pagination-item-${page}`);
}
async clickPagination({ page }: { page: string }) {
await this.waitForResponse({
uiAction: (await this.pagination({ page })).click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: '/views/',
responseJsonMatcher: resJson => resJson?.pageInfo,
});
await this.waitLoading();
}
async verifyActivePage({ page }: { page: string }) {
await expect(await this.pagination({ page })).toHaveClass(/ant-pagination-item-active/);
}
async waitLoading() {
await this.dashboard.get().locator('[data-nc="grid-load-spinner"]').waitFor({ state: 'hidden' });
}
async verifyEditDisabled({ columnHeader = 'Title' }: { columnHeader?: string } = {}) {
// double click to toggle to edit mode
const cell = this.cell.get({ index: 0, columnHeader: columnHeader });
await this.cell.dblclick({
index: 0,
columnHeader: columnHeader,
});
await expect(await cell.locator('input')).not.toBeVisible();
// right click menu
await this.get().locator(`td[data-nc="cell-${columnHeader}-0"]`).click({
button: 'right',
});
await expect(await this.rootPage.locator('text=Insert New Row')).not.toBeVisible();
// in cell-add
await this.cell.get({ index: 0, columnHeader: 'City List' }).hover();
await expect(
await this.cell.get({ index: 0, columnHeader: 'City List' }).locator('.nc-action-icon.nc-plus')
).not.toBeVisible();
// expand row
await this.cell.get({ index: 0, columnHeader: 'City List' }).hover();
await expect(
await this.cell.get({ index: 0, columnHeader: 'City List' }).locator('.nc-action-icon >> nth=0')
).not.toBeVisible();
}
async verifyEditEnabled({ columnHeader = 'Title' }: { columnHeader?: string } = {}) {
// double click to toggle to edit mode
const cell = this.cell.get({ index: 0, columnHeader: columnHeader });
await this.cell.dblclick({
index: 0,
columnHeader: columnHeader,
});
await expect(await cell.locator('input')).toBeVisible();
// right click menu
await this.get().locator(`td[data-nc="cell-${columnHeader}-0"]`).click({
button: 'right',
});
await expect(await this.rootPage.locator('text=Insert New Row')).toBeVisible();
// in cell-add
await this.cell.get({ index: 0, columnHeader: 'City List' }).hover();
await expect(
await this.cell.get({ index: 0, columnHeader: 'City List' }).locator('.nc-action-icon.nc-plus')
).toBeVisible();
// expand row
await this.cell.get({ index: 0, columnHeader: 'City List' }).hover();
await expect(
await this.cell.get({ index: 0, columnHeader: 'City List' }).locator('.nc-action-icon.nc-arrow-expand')
).toBeVisible();
}
async validateRoleAccess(param: { role: string }) {
await this.column.verifyRoleAccess(param);
await this.cell.verifyRoleAccess(param);
await expect(this.get().locator('.nc-grid-add-new-cell')).toHaveCount(
param.role === 'creator' || param.role === 'editor' ? 1 : 0
);
}
}

31
scripts/playwright/pages/Dashboard/Import/Airtable.ts

@ -0,0 +1,31 @@
import { Locator } from '@playwright/test';
import BasePage from '../../Base';
import { DashboardPage } from '..';
export class ImportAirtablePage extends BasePage {
readonly dashboard: DashboardPage;
readonly importButton: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.importButton = dashboard.get().locator('.nc-btn-airtable-import');
}
get() {
return this.dashboard.get().locator(`.nc-modal-airtable-import`);
}
async import({ key, baseId }: { key: string; baseId: string }) {
// kludge: failing in headless mode
// additional time to allow the modal to render completely
await this.rootPage.waitForTimeout(1000);
await this.get().locator(`.nc-input-api-key >> input`).fill(key);
await this.get().locator(`.nc-input-shared-base`).fill(baseId);
await this.importButton.click();
await this.get().locator(`button:has-text("Go to Dashboard")`).waitFor();
await this.get().locator(`button:has-text("Go to Dashboard")`).click();
}
}

79
scripts/playwright/pages/Dashboard/Import/ImportTemplate.ts

@ -0,0 +1,79 @@
import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { DashboardPage } from '..';
export class ImportTemplatePage extends BasePage {
readonly dashboard: DashboardPage;
readonly importButton: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.importButton = dashboard.get().locator('.nc-btn-import');
}
get() {
return this.dashboard.get().locator(`.nc-modal-quick-import`);
}
async getImportTableList() {
await this.get().locator(`.ant-collapse-header`).nth(0).waitFor();
const tr = await this.get().locator(`.ant-collapse-header`);
const rowCount = await tr.count();
const tableList: string[] = [];
for (let i = 0; i < rowCount; i++) {
const tableName = await tr.nth(i).innerText();
tableList.push(tableName);
}
return tableList;
}
async getImportColumnList() {
// return an array
const columnList: { type: string; name: string }[] = [];
const tr = await this.get().locator(`tr.ant-table-row-level-0:visible`);
const rowCount = await tr.count();
for (let i = 0; i < rowCount; i++) {
// replace \n and \t from innerText
const columnType = await tr
.nth(i)
.innerText()
.then(text => text.replace(/\n|\t/g, ''));
const columnName = await tr.nth(i).locator(`input[type="text"]`).inputValue();
columnList.push({ type: columnType, name: columnName });
}
return columnList;
}
// todo: Add polling logic to assertions
async import({ file, result }: { file: string; result: any }) {
const importFile = this.get().locator(`input[type="file"]`);
await importFile.setInputFiles(file);
await this.importButton.click();
const tblList = await this.getImportTableList();
for (let i = 0; i < result.length; i++) {
await expect(tblList[i]).toBe(result[i].name);
const columnList = await this.getImportColumnList();
await expect(columnList).toEqual(result[i].columns);
if (i < result.length - 1) {
await this.expandTableList({ index: i + 1 });
}
}
await this.get().locator('button:has-text("Back"):visible').waitFor();
await this.waitForResponse({
requestUrlPathToMatch: '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
uiAction: this.get().locator('button:has-text("Import"):visible').click(),
});
await this.dashboard.waitForTabRender({
title: tblList[0],
});
}
private async expandTableList(param: { index: number }) {
await this.get().locator(`.ant-collapse-header`).nth(param.index).click();
await this.rootPage.waitForTimeout(1000);
}
}

140
scripts/playwright/pages/Dashboard/Kanban/index.ts

@ -0,0 +1,140 @@
import { expect } from '@playwright/test';
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { ToolbarPage } from '../common/Toolbar';
export class KanbanPage extends BasePage {
readonly dashboard: DashboardPage;
readonly toolbar: ToolbarPage;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.toolbar = new ToolbarPage(this);
}
get() {
return this.dashboard.get().locator('[data-nc="nc-kanban-wrapper"]');
}
card(index: number) {
return this.get().locator(`.ant-card`).nth(index);
}
async openExpandedRow({ index }: { index: number }) {
await this.card(index).click();
await (await this.rootPage.locator('.ant-drawer-body').elementHandle())?.waitForElementState('stable');
}
// todo: Implement
async addOption() {}
// todo: Implement
async dragDropCard(param: { from: string; to: string }) {
// const { from, to } = param;
// const srcStack = await this.get().locator(`.nc-kanban-stack`).nth(1);
// const dstStack = await this.get().locator(`.nc-kanban-stack`).nth(2);
// const fromCard = await srcStack.locator(`.nc-kanban-item`).nth(1);
// const toCard = await dstStack.locator(`.nc-kanban-item`).nth(1);
// const [fromCard, toCard] = await Promise.all([
// srcStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0),
// dstStack.locator(`.nc-kanban-item[data-draggable="true"]`).nth(0),
// ]);
// const fromCard = await this.get().locator(`.nc-kanban-item`).nth(0);
// const toCard = await this.get().locator(`.nc-kanban-item`).nth(25);
// await fromCard.dragTo(toCard);
}
async dragDropStack(param: { from: number; to: number }) {
const { from, to } = param;
const [fromStack, toStack] = await Promise.all([
this.rootPage.locator(`.nc-kanban-stack-head`).nth(from),
this.rootPage.locator(`.nc-kanban-stack-head`).nth(to),
]);
await fromStack.dragTo(toStack);
}
async verifyStackCount(param: { count: number }) {
const { count } = param;
await expect(this.get().locator(`.nc-kanban-stack`)).toHaveCount(count);
}
async verifyStackOrder(param: { order: string[] }) {
const { order } = param;
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
// Since otherwise stack title will be repeated as title is in two divs, with one having hidden class
const stackTitle = await stack.locator(`.nc-kanban-stack-head >> [data-nc="truncate-label"]`);
await expect(stackTitle).toHaveText(order[i], { ignoreCase: true });
}
}
async verifyStackFooter(param: { count: number[] }) {
const { count } = param;
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
const stackFooter = await stack.locator(`.nc-kanban-data-count`).innerText();
await expect(stackFooter).toContain(`${count[i]} record${count[i] !== 1 ? 's' : ''}`);
}
}
async verifyCardCount(param: { count: number[] }) {
const { count } = param;
const stacks = await this.get().locator(`.nc-kanban-stack`).count();
for (let i = 0; i < stacks; i++) {
const stack = await this.get().locator(`.nc-kanban-stack`).nth(i);
const stackCards = stack.locator(`.nc-kanban-item`);
await expect(stackCards).toHaveCount(count[i]);
}
}
async verifyCardOrder(param: { order: string[]; stackIndex: number }) {
const { order, stackIndex } = param;
const stack = await this.get().locator(`.nc-kanban-stack`).nth(stackIndex);
for (let i = 0; i < order.length; i++) {
const card = await stack.locator(`.nc-kanban-item`).nth(i);
const cardTitle = await card.locator(`.nc-cell`);
await expect(cardTitle).toHaveText(order[i]);
}
}
// todo: Wait for render to complete
async waitLoading() {
await this.rootPage.waitForTimeout(1000);
}
async addNewStack(param: { title: string }) {
await this.toolbar.clickAddEditStack();
await this.toolbar.addEditStack.addOption({ title: param.title });
}
async collapseStack(param: { index: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click();
const modal = await this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Collapse Stack")').click();
}
async expandStack(param: { index: number }) {
await this.rootPage.locator(`.nc-kanban-collapsed-stack`).nth(param.index).click();
}
async verifyCollapseStackCount(param: { count: number }) {
await expect(this.rootPage.locator('.nc-kanban-collapsed-stack')).toHaveCount(param.count);
}
async addCard(param: { stackIndex: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.stackIndex).click();
const modal = await this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Add new record")').click();
}
async deleteStack(param: { index: number }) {
await this.get().locator(`.nc-kanban-stack-head`).nth(param.index).click();
const modal = await this.rootPage.locator(`.nc-dropdown-kanban-stack-context-menu`);
await modal.locator('.ant-dropdown-menu-item:has-text("Delete Stack")').click();
const confirmationModal = await this.rootPage.locator(`.nc-modal-kanban-delete-stack`);
await confirmationModal.locator(`button:has-text("Delete")`).click();
}
}

29
scripts/playwright/pages/Dashboard/Settings/Acl.ts

@ -0,0 +1,29 @@
import { expect, Locator } from '@playwright/test';
import { SettingsPage } from '.';
import BasePage from '../../Base';
export class AclPage extends BasePage {
private readonly settings: SettingsPage;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
}
get() {
return this.settings.get().locator(`[data-nc="nc-settings-subtab-UI Access Control"]`);
}
async toggle({ table, role }: { table: string; role: string }) {
await this.get().locator(`.nc-acl-${table}-${role}-chkbox`).click();
}
async save() {
await this.waitForResponse({
uiAction: this.get().locator(`button:has-text("Save")`).click(),
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: '/visibility-rules',
});
await this.verifyToast({ message: 'Updated UI ACL for tables successfully' });
}
}

43
scripts/playwright/pages/Dashboard/Settings/AppStore.ts

@ -0,0 +1,43 @@
import { expect } from '@playwright/test';
import { SettingsPage } from '.';
import BasePage from '../../Base';
export class AppStoreSettingsPage extends BasePage {
private readonly settings: SettingsPage;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
}
get() {
return this.settings.get().locator(`[data-nc="nc-settings-subtab-appStore"]`);
}
async install({ name }: { name: string }) {
const card = await this.settings.get().locator(`.nc-app-store-card-${name}`);
await card.click();
await card.locator('.nc-app-store-card-install').click();
}
async configureSlack() {}
async configureSMTP({ email, host, port }: { email: string; host: string; port: string }) {
const appStoreCard = this.rootPage.locator('.nc-modal-plugin-install');
await appStoreCard.locator('[id="form_item_from"]').fill(email);
await appStoreCard.locator('[id="form_item_host"]').fill(host);
await appStoreCard.locator('[id="form_item_port"]').fill(port);
await appStoreCard.locator('button:has-text("Save")').click();
}
async uninstall(param: { name: string }) {
const card = this.settings.get().locator(`.nc-app-store-card-${param.name}`);
// await card.scrollIntoViewIfNeeded();
await card.click();
await card.locator('.nc-app-store-card-reset').click();
await this.rootPage.locator('button.ant-btn-dangerous').click();
}
}

75
scripts/playwright/pages/Dashboard/Settings/Audit.ts

@ -0,0 +1,75 @@
import { expect } from '@playwright/test';
import { SettingsPage } from '.';
import BasePage from '../../Base';
export class AuditSettingsPage extends BasePage {
private readonly settings: SettingsPage;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
}
get() {
return this.settings.get().locator(`[data-nc="nc-settings-subtab-Audit"]`);
}
async verifyRow({
index,
opType,
opSubtype,
description,
user,
created,
}: {
index: number;
opType?: string;
opSubtype?: string;
description?: string;
user?: string;
created?: string;
}) {
const table = await this.get();
const row = table.locator(`tr.ant-table-row`).nth(index);
if (opType) {
await row
.locator(`td.ant-table-cell`)
.nth(0)
.textContent()
.then(async text => await expect(text).toContain(opType));
}
if (opSubtype) {
await row
.locator(`td.ant-table-cell`)
.nth(1)
.textContent()
.then(async text => await expect(text).toContain(opSubtype));
}
if (description) {
await row
.locator(`td.ant-table-cell`)
.nth(2)
.textContent()
.then(async text => await expect(text).toContain(description));
}
if (user) {
await row
.locator(`td.ant-table-cell`)
.nth(3)
.textContent()
.then(async text => await expect(text).toContain(user));
}
if (created) {
await row
.locator(`td.ant-table-cell`)
.nth(4)
.textContent()
.then(async text => await expect(text).toContain(created));
}
}
}

15
scripts/playwright/pages/Dashboard/Settings/Erd.ts

@ -0,0 +1,15 @@
import { SettingsPage } from '.';
import { ErdBasePage } from '../commonBase/Erd';
export class SettingsErdPage extends ErdBasePage {
readonly settings: SettingsPage;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
}
get() {
return this.rootPage.locator(`[data-nc="nc-settings-subtab-ERD View"]`);
}
}

48
scripts/playwright/pages/Dashboard/Settings/Metadata.ts

@ -0,0 +1,48 @@
import { expect } from '@playwright/test';
import { SettingsPage } from '.';
import BasePage from '../../Base';
export class MetaDataPage extends BasePage {
private readonly settings: SettingsPage;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
}
get() {
return this.settings.get().locator(`[data-nc="nc-settings-subtab-Metadata"]`);
}
async clickReload() {
await this.get().locator(`button:has-text("Reload")`).click();
// todo: Remove this wait
await this.rootPage.waitForTimeout(100);
// await this.get().locator(`.animate-spin`).waitFor({state: 'visible'});
await this.get().locator(`.animate-spin`).waitFor({ state: 'detached' });
}
async sync() {
await this.get().locator(`button:has-text("Sync Now")`).click();
await this.verifyToast({ message: 'Table metadata recreated successfully' });
await this.get().locator(`.animate-spin`).waitFor({ state: 'visible' });
await this.get().locator(`.animate-spin`).waitFor({ state: 'detached' });
}
async verifyRow({ index, model, state }: { index: number; model: string; state: string }) {
await expect
.poll(async () => {
return await this.get()
.locator(`tr.ant-table-row`)
.nth(index)
.locator(`td.ant-table-cell`)
.nth(0)
.textContent();
})
.toContain(model);
await expect(
await this.get().locator(`tr.ant-table-row`).nth(index).locator(`td.ant-table-cell`).nth(1).textContent()
).toContain(state);
}
}

20
scripts/playwright/pages/Dashboard/Settings/Miscellaneous.ts

@ -0,0 +1,20 @@
import { expect } from '@playwright/test';
import { SettingsPage } from '.';
import BasePage from '../../Base';
export class MiscSettingsPage extends BasePage {
private readonly settings: SettingsPage;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
}
get() {
return this.settings.get().locator(`[data-nc="nc-settings-subtab-Miscellaneous"]`);
}
async clickShowM2MTables() {
await this.get().locator('input[type="checkbox"]').click();
}
}

99
scripts/playwright/pages/Dashboard/Settings/Teams.ts

@ -0,0 +1,99 @@
import { expect, Locator } from '@playwright/test';
import { SettingsPage } from '.';
import BasePage from '../../Base';
import { writeFileAsync } from 'xlsx';
import { ToolbarPage } from '../common/Toolbar';
export class TeamsPage extends BasePage {
private readonly settings: SettingsPage;
readonly inviteTeamBtn: Locator;
readonly inviteTeamModal: Locator;
constructor(settings: SettingsPage) {
super(settings.rootPage);
this.settings = settings;
this.inviteTeamBtn = this.get().locator(`button:has-text("Invite Team")`);
this.inviteTeamModal = this.rootPage.locator(`.nc-modal-invite-user-and-share-base`);
}
get() {
return this.settings.get().locator(`[data-nc="nc-settings-subtab-Users Management"]`);
}
prefixEmail(email: string) {
const parallelId = process.env.TEST_PARALLEL_INDEX ?? '0';
return `nc_test_${parallelId}_${email}`;
}
getSharedBaseSubModal() {
return this.rootPage.locator(`[data-nc="nc-share-base-sub-modal"]`);
}
async invite({ email, role }: { email: string; role: string }) {
email = this.prefixEmail(email);
await this.inviteTeamBtn.click();
await this.inviteTeamModal.locator(`input[placeholder="E-mail"]`).fill(email);
await this.inviteTeamModal.locator(`.nc-user-roles`).click();
const userRoleModal = this.rootPage.locator(`.nc-dropdown-user-role`);
await userRoleModal.locator(`.nc-role-option:has-text("${role}")`).click();
await this.inviteTeamModal.locator(`button:has-text("Invite")`).click();
await this.verifyToast({ message: 'Successfully updated the user details' });
return await this.inviteTeamModal.locator(`.ant-alert-message`).innerText();
}
async closeInvite() {
// two btn-icon-only in invite modal: close & copy url
await this.inviteTeamModal.locator(`button.ant-btn-icon-only:visible`).first().click();
}
async inviteMore() {
await this.inviteTeamModal.locator(`button:has-text("Invite More")`).click();
}
async toggleSharedBase({ toggle }: { toggle: boolean }) {
const toggleBtn = await this.getSharedBaseSubModal().locator(`.nc-disable-shared-base`);
const toggleBtnText = await toggleBtn.first().innerText();
const disabledBase = toggleBtnText.includes('Disable');
if (disabledBase) {
if (toggle) {
// if share base was disabled && request was to enable
await toggleBtn.click();
const modal = await this.rootPage.locator(`.nc-dropdown-shared-base-toggle`);
await modal.locator(`.ant-dropdown-menu-title-content`).click();
}
} else {
if (!toggle) {
// if share base was enabled && request was to disable
await toggleBtn.click();
const modal = await this.rootPage.locator(`.nc-dropdown-shared-base-toggle`);
await modal.locator(`.ant-dropdown-menu-title-content`).click();
}
}
}
async getSharedBaseUrl() {
const url = await this.getSharedBaseSubModal().locator(`.nc-url:visible`).innerText();
return url;
}
async sharedBaseActions({ action }: { action: string }) {
const actionMenu = ['reload', 'copy url', 'open tab', 'copy embed code'];
const index = actionMenu.indexOf(action);
await this.getSharedBaseSubModal().locator(`button.ant-btn-icon-only`).nth(index).click();
}
async sharedBaseRole({ role }: { role: string }) {
// editor | viewer
// await this.getSharedBaseSubModal()
// .locator(`.nc-shared-base-role`)
// .waitFor();
await this.getSharedBaseSubModal().locator(`.nc-shared-base-role:visible`).click();
const userRoleModal = await this.rootPage.locator(`.nc-dropdown-share-base-role:visible`);
await userRoleModal.locator(`.ant-select-item-option-content:has-text("${role}"):visible`).click();
}
}

63
scripts/playwright/pages/Dashboard/Settings/index.ts

@ -0,0 +1,63 @@
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { AuditSettingsPage } from './Audit';
import { SettingsErdPage } from './Erd';
import { MetaDataPage } from './Metadata';
import { AppStoreSettingsPage } from './AppStore';
import { MiscSettingsPage } from './Miscellaneous';
import { TeamsPage } from './Teams';
import { AclPage } from './Acl';
export enum SettingTab {
TeamAuth = 'teamAndAuth',
AppStore = 'appStore',
ProjectMetadata = 'projMetaData',
Audit = 'audit',
}
export enum SettingsSubTab {
ERD = 'erd',
Miscellaneous = 'misc',
ACL = 'acl',
}
export class SettingsPage extends BasePage {
private readonly dashboard: DashboardPage;
readonly audit: AuditSettingsPage;
readonly appStore: AppStoreSettingsPage;
readonly metaData: MetaDataPage;
readonly miscellaneous: MiscSettingsPage;
readonly erd: SettingsErdPage;
readonly teams: TeamsPage;
readonly acl: AclPage;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.audit = new AuditSettingsPage(this);
this.appStore = new AppStoreSettingsPage(this);
this.metaData = new MetaDataPage(this);
this.miscellaneous = new MiscSettingsPage(this);
this.erd = new SettingsErdPage(this);
this.teams = new TeamsPage(this);
this.acl = new AclPage(this);
}
get() {
return this.rootPage.locator('.nc-modal-settings');
}
async selectTab({ tab, subTab }: { tab: SettingTab; subTab?: SettingsSubTab }) {
await this.get().locator(`li[data-menu-id="${tab}"]`).click();
if (subTab) await this.get().locator(`li[data-menu-id="${subTab}"]`).click();
}
async selectSubTab({ subTab }: { subTab: SettingsSubTab }) {
await this.get().locator(`li[data-menu-id="${subTab}"]`).click();
}
async close() {
await this.get().locator('[data-nc="settings-modal-close-button"]').click();
await this.get().waitFor({ state: 'hidden' });
}
}

86
scripts/playwright/pages/Dashboard/SurveyForm/index.ts

@ -0,0 +1,86 @@
import { expect, Locator, Page } from '@playwright/test';
import BasePage from '../../Base';
export class SurveyFormPage extends BasePage {
readonly formHeading: Locator;
readonly formSubHeading: Locator;
readonly submitButton: Locator;
readonly nextButton: Locator;
readonly nextSlideButton: Locator;
readonly prevSlideButton: Locator;
readonly darkModeButton: Locator;
readonly formFooter: Locator;
constructor(rootPage: Page) {
super(rootPage);
this.formHeading = this.get().locator('[data-nc="nc-survey-form__heading"]');
this.formSubHeading = this.get().locator('[data-nc="nc-survey-form__sub-heading"]');
this.submitButton = this.get().locator('[data-nc="nc-survey-form__btn-submit"]');
this.nextButton = this.get().locator('[data-nc="nc-survey-form__btn-next"]');
this.nextSlideButton = this.get().locator('[data-nc="nc-survey-form__icon-next"]');
this.prevSlideButton = this.get().locator('[data-nc="nc-survey-form__icon-prev"]');
this.darkModeButton = this.get().locator('[data-nc="nc-form-dark-mode"]');
this.formFooter = this.get().locator('[data-nc="nc-survey-form__footer"]');
}
get() {
return this.rootPage.locator('html >> .nc-form-view');
}
async validate({
heading,
subHeading,
fieldLabel,
footer,
}: {
heading: string;
subHeading: string;
fieldLabel: string;
footer: string;
}) {
await expect(this.get()).toBeVisible();
await expect(this.formHeading).toHaveText(heading);
await expect(this.formSubHeading).toHaveText(subHeading);
await expect(this.formFooter).toHaveText(footer);
await expect(this.get().locator(`[data-nc="nc-form-column-label"]`)).toHaveText(fieldLabel);
// parse footer text ("1 / 3") to identify if last slide
let isLastSlide = false;
const footerText = await this.formFooter.innerText();
const slideNumber = footerText.split(' / ')[0];
const totalSlides = footerText.split(' / ')[1];
if (slideNumber === totalSlides) {
isLastSlide = true;
}
if (isLastSlide) {
await expect(this.submitButton).toBeVisible();
} else {
await expect(this.nextButton).toBeVisible();
}
}
async fill(param: { fieldLabel: string; type?: string; value?: string }) {
await this.get().locator(`[data-nc="nc-survey-form__input-${param.fieldLabel}"]`).click();
if (param.type === 'SingleLineText') {
await this.get().locator(`[data-nc="nc-survey-form__input-${param.fieldLabel}"] >> input`).fill(param.value);
// press enter key
await this.get().locator(`[data-nc="nc-survey-form__input-${param.fieldLabel}"] >> input`).press('Enter');
} else if (param.type === 'DateTime') {
const modal = await this.rootPage.locator('.nc-picker-datetime');
await expect(modal).toBeVisible();
await modal.locator('.ant-picker-now-btn').click();
await modal.locator('.ant-picker-ok').click();
await this.nextButton.click();
}
}
async validateSuccessMessage(param: { message: string; showAnotherForm?: boolean }) {
await expect(
this.get().locator(`[data-nc="nc-survey-form__success-msg"]:has-text("${param.message}")`)
).toBeVisible();
if (param.showAnotherForm) {
await expect(this.get().locator(`button:has-text("Submit Another Form")`)).toBeVisible();
}
}
}

136
scripts/playwright/pages/Dashboard/TreeView.ts

@ -0,0 +1,136 @@
import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '.';
import BasePage from '../Base';
export class TreeViewPage extends BasePage {
readonly dashboard: DashboardPage;
readonly project: any;
readonly quickImportButton: Locator;
readonly inviteTeamButton: Locator;
constructor(dashboard: DashboardPage, project: any) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.project = project;
this.quickImportButton = dashboard.get().locator('.nc-import-menu');
this.inviteTeamButton = dashboard.get().locator('.nc-share-base');
}
get() {
return this.dashboard.get().locator('.nc-treeview-container');
}
async focusTable({ title }: { title: string }) {
await this.get().locator(`.nc-project-tree-tbl-${title}`).focus();
}
// assumption: first view rendered is always GRID
//
async openTable({ title, mode = 'standard' }: { title: string; mode?: string }) {
if ((await this.get().locator('.active.nc-project-tree-tbl').count()) > 0) {
if ((await this.get().locator('.active.nc-project-tree-tbl').innerText()) === title) {
// table already open
return;
}
}
await this.waitForResponse({
uiAction: this.get().locator(`.nc-project-tree-tbl-${title}`).click(),
httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: `/api/v1/db/data/noco/`,
responseJsonMatcher: json => json.pageInfo,
});
await this.dashboard.waitForTabRender({ title, mode });
}
async createTable({ title }: { title: string }) {
await this.get().locator('.nc-add-new-table').click();
await this.dashboard.get().locator('.nc-modal-table-create').locator('.ant-modal-body').waitFor();
await this.dashboard.get().locator('[placeholder="Enter table name"]').fill(title);
await this.waitForResponse({
uiAction: this.dashboard.get().locator('button:has-text("Submit")').click(),
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: `/api/v1/db/meta/projects/`,
responseJsonMatcher: json => json.title === title && json.type === 'table',
});
await this.dashboard.waitForTabRender({ title });
}
async verifyTable({ title, index, exists = true }: { title: string; index?: number; exists?: boolean }) {
if (exists) {
await expect(this.get().locator(`.nc-project-tree-tbl-${title}`)).toBeVisible();
if (index) {
await expect(await this.get().locator('.nc-tbl-title').nth(index)).toHaveText(title);
}
} else {
await expect(this.get().locator(`.nc-project-tree-tbl-${title}`)).toHaveCount(0);
}
}
async deleteTable({ title }: { title: string }) {
await this.get().locator(`.nc-project-tree-tbl-${title}`).click({ button: 'right' });
await this.dashboard.get().locator('div.nc-project-menu-item:has-text("Delete")').click();
await this.waitForResponse({
uiAction: this.dashboard.get().locator('button:has-text("Yes")').click(),
httpMethodsToMatch: ['DELETE'],
requestUrlPathToMatch: `/api/v1/db/meta/tables/`,
});
await expect
.poll(
async () =>
await this.dashboard.tabBar
.locator('.ant-tabs-tab', {
hasText: title,
})
.isVisible()
)
.toBe(false);
(await this.rootPage.locator('.nc-container').last().elementHandle())?.waitForElementState('stable');
}
async renameTable({ title, newTitle }: { title: string; newTitle: string }) {
await this.get().locator(`.nc-project-tree-tbl-${title}`).click({ button: 'right' });
await this.dashboard.get().locator('div.nc-project-menu-item:has-text("Rename")').click();
await this.dashboard.get().locator('[placeholder="Enter table name"]').fill(newTitle);
await this.dashboard.get().locator('button:has-text("Submit")').click();
await this.verifyToast({ message: 'Table renamed successfully' });
}
async reorderTables({ sourceTable, destinationTable }: { sourceTable: string; destinationTable: string }) {
await this.dashboard
.get()
.locator(`[data-nc="tree-view-table-draggable-handle-${sourceTable}"]`)
.dragTo(this.get().locator(`[data-nc="tree-view-table-${destinationTable}"]`));
}
async quickImport({ title }: { title: string }) {
await this.get().locator('.nc-add-new-table').hover();
await this.quickImportButton.click();
const importMenu = this.dashboard.get().locator('.nc-dropdown-import-menu');
await importMenu.locator(`.ant-dropdown-menu-title-content:has-text("${title}")`).click();
}
async validateRoleAccess(param: { role: string }) {
// Add new table button
await expect(this.get().locator(`.nc-add-new-table`)).toHaveCount(param.role === 'creator' ? 1 : 0);
// Import menu
await expect(this.get().locator(`.nc-import-menu`)).toHaveCount(param.role === 'creator' ? 1 : 0);
// Invite Team button
await expect(this.get().locator(`.nc-share-base`)).toHaveCount(param.role === 'creator' ? 1 : 0);
// Right click context menu
await this.get().locator(`.nc-project-tree-tbl-Country`).click({
button: 'right',
});
await expect(this.rootPage.locator(`.nc-dropdown-tree-view-context-menu:visible`)).toHaveCount(
param.role === 'creator' ? 1 : 0
);
}
}

132
scripts/playwright/pages/Dashboard/ViewSidebar/index.ts

@ -0,0 +1,132 @@
import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '../';
import BasePage from '../../Base';
export class ViewSidebarPage extends BasePage {
readonly project: any;
readonly dashboard: DashboardPage;
readonly createGalleryButton: Locator;
readonly createGridButton: Locator;
readonly createFormButton: Locator;
readonly createKanbanButton: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.createGalleryButton = this.get().locator('.nc-create-gallery-view:visible');
this.createGridButton = this.get().locator('.nc-create-grid-view:visible');
this.createFormButton = this.get().locator('.nc-create-form-view:visible');
this.createKanbanButton = this.get().locator('.nc-create-kanban-view:visible');
}
get() {
return this.dashboard.get().locator('.nc-view-sidebar');
}
private async createView({ title, locator }: { title: string; locator: Locator }) {
await locator.click();
await this.rootPage.locator('input[id="form_item_title"]:visible').fill(title);
const submitAction = this.rootPage
.locator('.ant-modal-content')
.locator('button:has-text("Submit"):visible')
.click();
await this.waitForResponse({
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: '/api/v1/db/meta/tables/',
uiAction: submitAction,
responseJsonMatcher: json => json.title === title,
});
await this.verifyToast({ message: 'View created successfully' });
// Todo: Wait for view to be rendered
await this.rootPage.waitForTimeout(1000);
}
async createGalleryView({ title }: { title: string }) {
await this.createView({ title, locator: this.createGalleryButton });
}
async createGridView({ title }: { title: string }) {
await this.createView({ title, locator: this.createGridButton });
}
async createFormView({ title }: { title: string }) {
await this.createView({ title, locator: this.createFormButton });
}
async openView({ title }: { title: string }) {
await this.get().locator(`[data-nc="view-sidebar-view-${title}"]`).click();
}
async createKanbanView({ title }: { title: string }) {
await this.createView({ title, locator: this.createKanbanButton });
}
// Todo: Make selection better
async verifyView({ title, index }: { title: string; index: number }) {
await expect(
this.get().locator('[data-nc="view-item"]').nth(index).locator('[data-nc="truncate-label"]')
).toHaveText(title, { ignoreCase: true });
}
async verifyViewNotPresent({ title, index }: { title: string; index: number }) {
const viewList = this.get().locator(`.nc-views-menu`).locator('.ant-menu-title-content');
if ((await viewList.count()) <= index) {
return true;
}
return await expect(
this.get().locator(`.nc-views-menu`).locator('.ant-menu-title-content').nth(index)
).not.toHaveText(title);
}
async reorderViews({ sourceView, destinationView }: { sourceView: string; destinationView: string }) {
await this.dashboard
.get()
.locator(`[data-nc="view-sidebar-drag-handle-${sourceView}"]`)
.dragTo(this.get().locator(`[data-nc="view-sidebar-view-${destinationView}"]`));
}
async deleteView({ title }: { title: string }) {
await this.get().locator(`[data-nc="view-sidebar-view-${title}"]`).hover();
await this.get().locator(`[data-nc="view-sidebar-view-actions-${title}"]`).locator('.nc-view-delete-icon').click();
await this.rootPage.locator('.nc-modal-view-delete').locator('button:has-text("Submit"):visible').click();
// waiting for button to get detached, we will miss toast
// await this.rootPage
// .locator(".nc-modal-view-delete")
// .locator('button:has-text("Submit")')
// .waitFor({ state: "detached" });
await this.verifyToast({ message: 'View deleted successfully' });
}
async renameView({ title, newTitle }: { title: string; newTitle: string }) {
await this.get().locator(`[data-nc="view-sidebar-view-${title}"]`).dblclick();
await this.get().locator(`[data-nc="view-sidebar-view-${title}"]`).locator('input').fill(newTitle);
await this.get().press('Enter');
await this.verifyToast({ message: 'View renamed successfully' });
}
async copyView({ title }: { title: string }) {
await this.get().locator(`[data-nc="view-sidebar-view-${title}"]`).hover();
await this.get().locator(`[data-nc="view-sidebar-view-actions-${title}"]`).locator('.nc-view-copy-icon').click();
const submitAction = this.rootPage
.locator('.ant-modal-content')
.locator('button:has-text("Submit"):visible')
.click();
await this.waitForResponse({
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: '/api/v1/db/meta/tables/',
uiAction: submitAction,
});
await this.verifyToast({ message: 'View created successfully' });
}
async validateRoleAccess(param: { role: string }) {
const count = param.role === 'creator' ? 1 : 0;
await expect(this.createGridButton).toHaveCount(count);
await expect(this.createGalleryButton).toHaveCount(count);
await expect(this.createFormButton).toHaveCount(count);
await expect(this.createKanbanButton).toHaveCount(count);
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save