Browse Source

Merge pull request #4465 from nocodb/develop

pull/4466/head 0.99.0
github-actions[bot] 2 years ago committed by GitHub
parent
commit
7e841f3b4f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 822
      .github/workflows/ci-cd.yml
  2. 147
      .github/workflows/playwright-test-workflow.yml
  3. 8
      .github/workflows/release-docker.yml
  4. 2
      .github/workflows/release-previous-docker.yml
  5. 2
      package.json
  6. 12
      packages/nc-cli/package-lock.json
  7. 27
      packages/nc-gui/app.vue
  8. BIN
      packages/nc-gui/assets/img/brand/Transparent.png
  9. BIN
      packages/nc-gui/assets/img/brand/favicon-128.png
  10. BIN
      packages/nc-gui/assets/img/brand/favicon-16.png
  11. BIN
      packages/nc-gui/assets/img/brand/favicon-32.png
  12. BIN
      packages/nc-gui/assets/img/brand/favicon-64.png
  13. BIN
      packages/nc-gui/assets/img/brand/full-logo.png
  14. BIN
      packages/nc-gui/assets/img/brand/text.png
  15. BIN
      packages/nc-gui/assets/img/icon.png
  16. BIN
      packages/nc-gui/assets/img/icons/256.png
  17. 15
      packages/nc-gui/assets/style.scss
  18. 9
      packages/nc-gui/components.d.ts
  19. 8
      packages/nc-gui/components/account/AppStore.vue
  20. 45
      packages/nc-gui/components/account/License.vue
  21. 146
      packages/nc-gui/components/account/ResetPassword.vue
  22. 56
      packages/nc-gui/components/account/SignupSettings.vue
  23. 262
      packages/nc-gui/components/account/Token.vue
  24. 283
      packages/nc-gui/components/account/UserList.vue
  25. 256
      packages/nc-gui/components/account/UsersModal.vue
  26. 21
      packages/nc-gui/components/cell/Checkbox.vue
  27. 5
      packages/nc-gui/components/cell/Currency.vue
  28. 42
      packages/nc-gui/components/cell/DatePicker.vue
  29. 34
      packages/nc-gui/components/cell/DateTimePicker.vue
  30. 5
      packages/nc-gui/components/cell/Decimal.vue
  31. 5
      packages/nc-gui/components/cell/Duration.vue
  32. 17
      packages/nc-gui/components/cell/Email.vue
  33. 5
      packages/nc-gui/components/cell/Float.vue
  34. 5
      packages/nc-gui/components/cell/Integer.vue
  35. 224
      packages/nc-gui/components/cell/MultiSelect.vue
  36. 12
      packages/nc-gui/components/cell/Percent.vue
  37. 19
      packages/nc-gui/components/cell/Rating.vue
  38. 175
      packages/nc-gui/components/cell/SingleSelect.vue
  39. 9
      packages/nc-gui/components/cell/Text.vue
  40. 5
      packages/nc-gui/components/cell/TextArea.vue
  41. 32
      packages/nc-gui/components/cell/TimePicker.vue
  42. 5
      packages/nc-gui/components/cell/Url.vue
  43. 34
      packages/nc-gui/components/cell/YearPicker.vue
  44. 21
      packages/nc-gui/components/cell/attachment/index.vue
  45. 9
      packages/nc-gui/components/dashboard/TreeView.vue
  46. 2
      packages/nc-gui/components/dashboard/settings/AuditTab.vue
  47. 4
      packages/nc-gui/components/dashboard/settings/Modal.vue
  48. 40
      packages/nc-gui/components/dlg/TableCreate.vue
  49. 1
      packages/nc-gui/components/dlg/TableRename.vue
  50. 2
      packages/nc-gui/components/dlg/ViewCreate.vue
  51. 2
      packages/nc-gui/components/general/TruncateText.vue
  52. 19
      packages/nc-gui/components/general/language/Menu.vue
  53. 6
      packages/nc-gui/components/shared-view/AskPassword.vue
  54. 4
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  55. 124
      packages/nc-gui/components/smartsheet/Cell.vue
  56. 46
      packages/nc-gui/components/smartsheet/Form.vue
  57. 4
      packages/nc-gui/components/smartsheet/Gallery.vue
  58. 239
      packages/nc-gui/components/smartsheet/Grid.vue
  59. 2
      packages/nc-gui/components/smartsheet/Kanban.vue
  60. 2
      packages/nc-gui/components/smartsheet/Pagination.vue
  61. 34
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  62. 9
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  63. 19
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  64. 50
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  65. 13
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  66. 132
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  67. 89
      packages/nc-gui/components/smartsheet/header/CellIcon.vue
  68. 27
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  69. 75
      packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts
  70. 8
      packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue
  71. 21
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  72. 9
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  73. 11
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  74. 16
      packages/nc-gui/components/smartsheet/toolbar/KanbanStackEditOrAdd.vue
  75. 121
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  76. 9
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  77. 11
      packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue
  78. 9
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  79. 134
      packages/nc-gui/components/tabs/auth/UserManagement.vue
  80. 2
      packages/nc-gui/components/tabs/auth/user-management/ShareBase.vue
  81. 10
      packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue
  82. 10
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  83. 36
      packages/nc-gui/components/virtual-cell/Formula.vue
  84. 10
      packages/nc-gui/components/virtual-cell/HasMany.vue
  85. 147
      packages/nc-gui/components/virtual-cell/Lookup.vue
  86. 11
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  87. 37
      packages/nc-gui/components/virtual-cell/Rollup.vue
  88. 13
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  89. 57
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  90. 10
      packages/nc-gui/composables/useApi/index.ts
  91. 96
      packages/nc-gui/composables/useColumn.ts
  92. 30
      packages/nc-gui/composables/useColumnCreateStore.ts
  93. 3
      packages/nc-gui/composables/useExpandedFormStore.ts
  94. 14
      packages/nc-gui/composables/useGlobal/state.ts
  95. 28
      packages/nc-gui/composables/useMenuCloseOnEsc/index.ts
  96. 32
      packages/nc-gui/composables/useMultiSelect/index.ts
  97. 8
      packages/nc-gui/composables/useProject.ts
  98. 21
      packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts
  99. 25
      packages/nc-gui/composables/useSmartsheetRowStore.ts
  100. 1
      packages/nc-gui/composables/useTable.ts
  101. Some files were not shown because too many files have changed in this diff Show More

822
.github/workflows/ci-cd.yml

@ -25,651 +25,6 @@ concurrency:
cancel-in-progress: true
jobs:
cypress-restTableOps-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/restTableOps.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-restTableOps-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-restViews-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/restViews.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-restViews-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-restRoles-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/restRoles.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-restRoles-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-restMisc-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/restMisc.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-restMisc-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-xcdb-restTableOps-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:xcdb-api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/xcdb-restTableOps.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-xcdb-restTableOps-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-xcdb-restViews-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:xcdb-api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/xcdb-restViews.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-xcdb-restViews-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-xcdb-restRoles-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:xcdb-api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/xcdb-restRoles.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-xcdb-restRoles-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-xcdb-restMisc-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:xcdb-api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/xcdb-restMisc.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-xcdb-restMisc-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-pg-restTableOps-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d
spec: "./scripts/cypress/integration/test/pg-restTableOps.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-pg-restTableOps-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-pg-restViews-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d
spec: "./scripts/cypress/integration/test/pg-restViews.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-pg-restViews-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-pg-restRoles-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d
spec: "./scripts/cypress/integration/test/pg-restRoles.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-pg-restRoles-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cypress-pg-restMisc-run-cache:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-pg.yml up -d
spec: "./scripts/cypress/integration/test/pg-restMisc.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-pg-restMisc-run-cache-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cy-quick-sqlite:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
cp ./scripts/cypress/fixtures/quickTest/noco_0_91_7.db ./packages/nocodb/noco.db
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/quickTest.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cy-quick-sqlite-snapshots
path: scripts/cypress/screenshots
retention-days: 2
cy-quick-pg:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
docker-compose -f ./scripts/cypress/docker-compose-pg-cy-quick.yml up -d
npm run start:api:cache:pg:cyquick
npm run start:web
spec: "./scripts/cypress/integration/test/quickTest.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cy-quick-pg-snapshots
path: scripts/cypress/screenshots
retention-days: 2
unit-tests:
runs-on: ubuntu-20.04
timeout-minutes: 30
@ -711,144 +66,39 @@ jobs:
- name: run unit tests
working-directory: ./packages/nocodb
run: npm run test:unit
cypress-db-independent:
runs-on: ubuntu-20.04
timeout-minutes: 30
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- 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: Set env
run: echo "NODE_ENV=test" >> $GITHUB_ENV
- name: Cypress run
uses: cypress-io/github-action@v4
with:
start: |
npm run start:api:cache
npm run start:web
docker-compose -f ./scripts/cypress/docker-compose-cypress.yml up -d
spec: "./scripts/cypress/integration/test/db-independent.js"
wait-on: "http://localhost:8080, http://localhost:3000/_nuxt/assets/img/icons/512x512-trans.png"
wait-on-timeout: 1200
config-file: scripts/cypress/cypress.json
- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v3
with:
name: cypress-restMisc-run-cache-snapshots
path: scripts/cypress/screenshots
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
playwright-mysql-1:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
uses: ./.github/workflows/playwright-test-workflow.yml
with:
db: mysql
shard: 1
playwright-mysql-2:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
uses: ./.github/workflows/playwright-test-workflow.yml
with:
db: mysql
shard: 2
playwright-sqlite-1:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
uses: ./.github/workflows/playwright-test-workflow.yml
with:
db: sqlite
shard: 1
playwright-sqlite-2:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
uses: ./.github/workflows/playwright-test-workflow.yml
with:
db: sqlite
shard: 2
playwright-pg-shard-1:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
uses: ./.github/workflows/playwright-test-workflow.yml
with:
db: pg
shard: 1
playwright-pg-shard-2:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
uses: ./.github/workflows/playwright-test-workflow.yml
with:
db: pg
shard: 2

147
.github/workflows/playwright-test-workflow.yml

@ -0,0 +1,147 @@
name: Playwright test reusable workflow
on:
workflow_call:
inputs:
shard:
description: 'Shard number'
required: true
type: string
db:
required: true
type: string
jobs:
playwright:
runs-on: ubuntu-20.04
timeout-minutes: 30
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
- 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: setup mysql
if: ${{ inputs.db == 'mysql' }}
working-directory: ./
run: docker-compose -f ./tests/playwright/scripts/docker-compose-mysql-playwright.yml up -d &
- name: setup pg
if: ${{ inputs.db == 'pg' }}
working-directory: ./
run: docker-compose -f ./tests/playwright/scripts/docker-compose-playwright-pg.yml up -d &
- name: setup pg for quick tests
if: ${{ inputs.db == 'sqlite' && inputs.shard == '1' }}
working-directory: ./
run: docker-compose -f ./tests/playwright/scripts/docker-compose-pg-pw-quick.yml up -d &
- name: run frontend
working-directory: ./packages/nc-gui
run: npm run ci:run
- name: Run backend
working-directory: ./packages/nocodb
run: |
npm install
npm run watch:run:playwright > ${{ inputs.db }}_${{ inputs.shard }}_test_backend.log &
- name: Cache playwright npm modules
uses: actions/cache@v3
id: playwright-cache
with:
path: |
**/tests/playwright/node_modules
key: cache-nc-playwright-${{ hashFiles('**/tests/playwright/package-lock.json') }}
- name: Install dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: ./tests/playwright
run: npm install
- name: Install Playwright Browsers
working-directory: ./tests/playwright
run: npx playwright install chromium --with-deps
- 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: ./tests/playwright
run: E2E_DB_TYPE=${{ inputs.db }} npm run ci:test:shard:${{ inputs.shard }}
# Quick tests (pg on sqlite shard 0 and sqlite on sqlite shard 1)
- name: Run quick server and tests (pg)
if: ${{ inputs.db == 'sqlite' && inputs.shard == '1' }}
working-directory: ./packages/nocodb
run: |
kill -9 $(lsof -t -i:8080)
npm run watch:run:playwright:pg:cyquick &
- name: Run quick server and tests (sqlite)
if: ${{ inputs.db == 'sqlite' && inputs.shard == '2' }}
working-directory: ./packages/nocodb
run: |
kill -9 $(lsof -t -i:8080)
npm run watch:run:playwright:quick > quick_${{ inputs.shard }}_test_backend.log &
- name: Wait for backend & run quick tests
if: ${{ inputs.db == 'sqlite' }}
working-directory: ./tests/playwright
run: |
while ! curl --output /dev/null --silent --head --fail http://localhost:8080; do
printf '.'
sleep 2
done
PLAYWRIGHT_HTML_REPORT=playwright-report-quick npm run test:quick
- uses: actions/upload-artifact@v3
if: ${{ inputs.db == 'sqlite' }}
with:
name: quick-backend-log-${{ inputs.shard }}
path: ./packages/nocodb/quick_${{ inputs.shard }}_test_backend.log
retention-days: 2
- uses: actions/upload-artifact@v3
if: ${{ inputs.db == 'sqlite' }}
with:
name: playwright-report-quick-${{ inputs.shard }}
path: ./tests/playwright/playwright-report-quick/
retention-days: 2
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-${{ inputs.db }}-${{ inputs.shard }}
path: ./tests/playwright/playwright-report/
retention-days: 2
- uses: actions/upload-artifact@v3
if: always()
with:
name: backend-logs-${{ inputs.db }}-${{ inputs.shard }}
path: ./packages/nocodb/${{ inputs.db }}_${{ inputs.shard }}_test_backend.log
retention-days: 2

8
.github/workflows/release-docker.yml

@ -103,11 +103,11 @@ jobs:
working-directory: ${{ env.working-directory }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2.2.1
- name: Cache Docker layers
uses: actions/cache@v3
@ -118,13 +118,13 @@ jobs:
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v3.2.0
with:
context: ${{ env.working-directory }}
build-args: NC_VERSION=${{ steps.get-docker-repository.outputs.DOCKER_BUILD_TAG }}

2
.github/workflows/release-previous-docker.yml

@ -14,7 +14,7 @@ jobs:
steps:
-
name: Login to Docker Hub
uses: docker/login-action@v1
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

2
package.json

@ -36,7 +36,7 @@
]
},
"scripts": {
"lint:staged:playwright": "cd scripts/playwright; npx lint-staged; cd ..",
"lint:staged:playwright": "cd ./tests/playwright; npx lint-staged; cd -",
"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",
"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",

12
packages/nc-cli/package-lock.json generated

@ -9008,9 +9008,9 @@
}
},
"node_modules/loader-utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"dependencies": {
"big.js": "^5.2.2",
@ -22415,9 +22415,9 @@
"dev": true
},
"loader-utils": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"requires": {
"big.js": "^5.2.2",

27
packages/nc-gui/app.vue

@ -6,12 +6,37 @@ const route = useRoute()
const disableBaseLayout = computed(() => route.path.startsWith('/nc/view') || route.path.startsWith('/nc/form'))
useTheme()
// TODO: Remove when https://github.com/vuejs/core/issues/5513 fixed
const key = ref(0)
const messages = [
`Uncaught NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.`, // chromium based
`NotFoundError: The object can not be found here.`, // safari
"Cannot read properties of null (reading 'parentNode')",
]
if (typeof window !== 'undefined') {
// @ts-expect-error using arbitrary window key
if (!window.__ncvue) {
window.addEventListener('error', (event) => {
if (messages.includes(event.message)) {
event.preventDefault()
console.warn('Re-rendering layout because of https://github.com/vuejs/core/issues/5513')
key.value++
}
})
}
// @ts-expect-error using arbitrary window key
window.__ncvue = true
}
</script>
<template>
<a-config-provider>
<NuxtLayout :name="disableBaseLayout ? false : 'base'">
<NuxtPage />
<NuxtPage :key="key" />
</NuxtLayout>
</a-config-provider>
</template>

BIN
packages/nc-gui/assets/img/brand/Transparent.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-128.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-16.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

BIN
packages/nc-gui/assets/img/brand/favicon-32.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

BIN
packages/nc-gui/assets/img/brand/favicon-64.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

BIN
packages/nc-gui/assets/img/brand/full-logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
packages/nc-gui/assets/img/brand/text.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
packages/nc-gui/assets/img/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

BIN
packages/nc-gui/assets/img/icons/256.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

15
packages/nc-gui/assets/style.scss

@ -214,17 +214,13 @@ a {
@apply z-1 relative color-transition rounded-md px-4 py-2 text-white;
&::after {
@apply ring-opacity-100 ring-[2px] ring-slate-300 rounded absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary bg-opacity-100;
@apply rounded absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary bg-opacity-100;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-accent;
}
&:active::after {
@apply ring ring-accent;
@apply transform scale-110;
}
}
@ -261,7 +257,7 @@ a {
}
.ant-dropdown-menu-item, .ant-menu-item {
@apply !py-0 active:(ring ring-accent ring-opacity-100);
@apply py-0;
}
.ant-dropdown-menu-title-content,
@ -285,3 +281,8 @@ a {
.ant-modal {
@apply !top-[50px];
}
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
@apply bg-primary bg-opacity-20;
}

9
packages/nc-gui/components.d.ts vendored

@ -24,6 +24,7 @@ declare module '@vue/runtime-core' {
ADivider: typeof import('ant-design-vue/es')['Divider']
ADrawer: typeof import('ant-design-vue/es')['Drawer']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
ADropdownButton: typeof import('ant-design-vue/es')['DropdownButton']
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
@ -104,8 +105,11 @@ declare module '@vue/runtime-core' {
MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default']
MdiAccount: typeof import('~icons/mdi/account')['default']
MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default']
MdiAccountCircleOutline: typeof import('~icons/mdi/account-circle-outline')['default']
MdiAccountOutline: typeof import('~icons/mdi/account-outline')['default']
MdiAccountPlusOutline: typeof import('~icons/mdi/account-plus-outline')['default']
MdiAccountSupervisorOutline: typeof import('~icons/mdi/account-supervisor-outline')['default']
MdiAdd: typeof import('~icons/mdi/add')['default']
MdiAlpha: typeof import('~icons/mdi/alpha')['default']
MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default']
MdiApi: typeof import('~icons/mdi/api')['default']
@ -121,6 +125,7 @@ declare module '@vue/runtime-core' {
MdiBugOutline: typeof import('~icons/mdi/bug-outline')['default']
MdiCalculator: typeof import('~icons/mdi/calculator')['default']
MdiCalendarMonth: typeof import('~icons/mdi/calendar-month')['default']
MdiCancel: typeof import('~icons/mdi/cancel')['default']
MdiCardsHeart: typeof import('~icons/mdi/cards-heart')['default']
MdiCellphoneMessage: typeof import('~icons/mdi/cellphone-message')['default']
MdiChat: typeof import('~icons/mdi/chat')['default']
@ -138,12 +143,14 @@ declare module '@vue/runtime-core' {
MdiCommentTextOutline: typeof import('~icons/mdi/comment-text-outline')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
MdiContentSave: typeof import('~icons/mdi/content-save')['default']
MdiContentSaveEdit: typeof import('~icons/mdi/content-save-edit')['default']
MdiCurrencyUsd: typeof import('~icons/mdi/currency-usd')['default']
MdiDatabaseOutline: typeof import('~icons/mdi/database-outline')['default']
MdiDatabaseSync: typeof import('~icons/mdi/database-sync')['default']
MdiDelete: typeof import('~icons/mdi/delete')['default']
MdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
MdiDiscord: typeof import('~icons/mdi/discord')['default']
MdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
MdiDownload: typeof import('~icons/mdi/download')['default']
MdiDownloadOutline: typeof import('~icons/mdi/download-outline')['default']
@ -194,12 +201,14 @@ declare module '@vue/runtime-core' {
MdiPlus: typeof import('~icons/mdi/plus')['default']
MdiPlusCircleOutline: typeof import('~icons/mdi/plus-circle-outline')['default']
MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default']
MdiPlusThick: typeof import('~icons/mdi/plus-thick')['default']
MdiReddit: typeof import('~icons/mdi/reddit')['default']
MdiRefresh: typeof import('~icons/mdi/refresh')['default']
MdiReload: typeof import('~icons/mdi/reload')['default']
MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default']
MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-outline')['default']
MdiScriptTextOutline: typeof import('~icons/mdi/script-text-outline')['default']
MdiShieldKeyOutline: typeof import('~icons/mdi/shield-key-outline')['default']
MdiSlack: typeof import('~icons/mdi/slack')['default']
MdiSort: typeof import('~icons/mdi/sort')['default']
MdiStar: typeof import('~icons/mdi/star')['default']

8
packages/nc-gui/components/account/AppStore.vue

@ -0,0 +1,8 @@
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('title.appStore') }}</div>
<div>
<LazyDashboardSettingsAppStore />
</div>
</div>
</template>

45
packages/nc-gui/components/account/License.vue

@ -0,0 +1,45 @@
<script lang="ts" setup>
import { useNuxtApp } from '#app'
import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi } from '#imports'
const { api, isLoading } = useApi()
const {$e} = useNuxtApp()
let key = $ref('')
const loadLicense = async () => {
try {
const response = await api.orgLicense.get()
key = response.key
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const setLicense = async () => {
try {
await api.orgLicense.set({ key: key })
message.success('License key updated')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:license')
}
loadLicense()
</script>
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">License</div>
<div>
<a-textarea v-model:value="key" placeholder="License key" class="!mt-2 !max-w-[600px]"></a-textarea>
</div>
<a-button class="mt-4" @click="setLicense" type="primary">Save license key</a-button>
</div>
</template>
<style scoped></style>

146
packages/nc-gui/components/account/ResetPassword.vue

@ -0,0 +1,146 @@
<script lang="ts" setup>
import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n } from '#imports'
const { api, error } = useApi({ useGlobalInstance: true })
const { t } = useI18n()
const { signOut } = useGlobal()
const formValidator = ref()
const form = reactive({
currentPassword: '',
password: '',
passwordRepeat: '',
})
const formRules = {
currentPassword: [
// Current password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
],
passwordRepeat: [
// PasswordRepeat is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
// Passwords match
{
validator: (_: unknown, _v: string) => {
return new Promise((resolve, reject) => {
if (form.password === form.passwordRepeat) return resolve(true)
reject(new Error(t('msg.error.signUpRules.passwdMismatch')))
})
},
message: t('msg.error.signUpRules.passwdMismatch'),
},
],
}
const passwordChange = async () => {
const valid = formValidator.value.validate()
if (!valid) return
error.value = null
await api.auth.passwordChange({
currentPassword: form.currentPassword,
newPassword: form.password,
})
message.success(t('msg.success.passwordChanged'))
signOut()
navigateTo('/signin')
}
const resetError = () => {
if (error.value) error.value = null
}
</script>
<template>
<div class="mx-auto relative flex flex-col justify-center gap-2 w-full px-8 md:(bg-white) max-w-[900px]">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('activity.changePwd') }}</div>
<a-form
ref="formValidator"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full !mx-auto"
no-style
:model="form"
@finish="passwordChange"
>
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.current')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.new')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat">
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
size="large"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@focus="resetError"
/>
</a-form-item>
<div class="text-center">
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2">
<MdiKeyChange />
{{ $t('activity.changePwd') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
<style lang="scss">
.change-password {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

56
packages/nc-gui/components/account/SignupSettings.vue

@ -0,0 +1,56 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi } from '#imports'
const { api } = useApi()
const { t } = useI18n()
let settings = $ref<{ invite_only_signup?: boolean }>({ invite_only_signup: false })
const loadSettings = async () => {
try {
const response = await api.orgAppSettings.get()
settings = response
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const saveSettings = async () => {
try {
await api.orgAppSettings.set(settings)
message.success(t('msg.success.settingsSaved'))
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadSettings()
</script>
<template>
<div data-testid="nc-app-settings">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Settings</div>
<div class="flex justify-center">
<a-form-item>
<a-checkbox
v-model:checked="settings.invite_only_signup"
v-e="['c:account:enable-signup']"
class="nc-checkbox nc-invite-only-signup-checkbox"
name="virtual"
@change="saveSettings"
>
{{ $t('labels.inviteOnlySignup') }}
</a-checkbox>
</a-form-item>
</div>
</div>
</template>
<style scoped>
:deep(.ant-checkbox-wrapper) {
@apply !flex-row-reverse !flex !justify-start gap-4;
justify-content: start;
}
</style>

262
packages/nc-gui/components/account/Token.vue

@ -0,0 +1,262 @@
<script lang="ts" setup>
import { Empty, Modal, message } from 'ant-design-vue'
import type { ApiTokenType, RequestParams, UserType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useApi, useCopy, useNuxtApp } from '#imports'
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
const { copy } = useCopy()
const { t } = useI18n()
let tokens = $ref<UserType[]>([])
let currentPage = $ref(1)
let showNewTokenModal = $ref(false)
const currentLimit = $ref(10)
let selectedTokenData = $ref<ApiTokenType>({})
const searchText = ref<string>('')
const pagination = reactive({
total: 0,
pageSize: 10,
})
const loadTokens = async (page = currentPage, limit = currentLimit) => {
currentPage = page
try {
const response: any = await api.orgTokens.list({
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
},
} as RequestParams)
if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
tokens = response.list as UserType[]
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadTokens()
const deleteToken = async (token: string) => {
Modal.confirm({
title: t('msg.info.deleteTokenConfirmation'),
type: 'warn',
onOk: async () => {
try {
// todo: delete token
await api.orgTokens.delete(token)
message.success(t('msg.success.tokenDeleted'))
await loadTokens()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:token:delete')
},
})
}
const generateToken = async () => {
try {
await api.orgTokens.create(selectedTokenData)
showNewTokenModal = false
// Token generated successfully
message.success(t('msg.success.tokenGenerated'))
selectedTokenData = {}
await loadTokens()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:generate')
}
const copyToken = (token: string | undefined) => {
if (!token) return
copy(token)
// Copied to clipboard
message.info(t('msg.info.copiedToClipboard'))
$e('c:api-token:copy')
}
const descriptionInput = (el) => {
el?.focus()
}
</script>
<template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Token Management</div>
<div class="max-w-[900px] mx-auto p-4" data-testid="nc-token-list">
<div class="py-2 flex gap-4 items-center">
<div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadTokens" />
<a-button data-testid="nc-token-create" size="small" type="primary" @click="showNewTokenModal = true">
<div class="flex items-center gap-1">
<MdiAdd />
Add new token
</div>
</a-button>
</div>
<a-table
:row-key="(record) => record.id"
:data-source="tokens"
:pagination="{ position: ['bottomCenter'] }"
:loading="isLoading"
size="small"
@change="loadTokens($event.current)"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<!-- Created By -->
<a-table-column key="created_by" :title="$t('labels.createdBy')" data-index="created_by">
<template #default="{ text }">
<div v-if="text">
{{ text }}
</div>
<div v-else class="text-gray-400">N/A</div>
</template>
</a-table-column>
<!-- Description -->
<a-table-column key="description" :title="$t('labels.description')" data-index="description">
<template #default="{ text }">
{{ text }}
</template>
</a-table-column>
<!-- Token -->
<a-table-column key="token" :title="$t('labels.token')" data-index="token">
<template #default="{ text, record }">
<div class="w-[320px]">
<span v-if="record.show">{{ text }}</span>
<span v-else>*******************************************</span>
</div>
</template>
</a-table-column>
<!-- Actions -->
<a-table-column key="actions" :title="$t('labels.actions')" data-index="token">
<template #default="{ record }">
<div class="flex items-center gap-2">
<a-tooltip placement="bottom">
<template #title>
<span v-if="record.show"> {{ $t('general.hide') }} </span>
<span v-else> {{ $t('general.show') }} </span>
</template>
<a-button type="text" class="!rounded-md nc-toggle-token-visibility" @click="record.show = !record.show">
<template #icon>
<MaterialSymbolsVisibilityOff v-if="record.show" class="flex mx-auto h-[1.1rem]" />
<MaterialSymbolsVisibility v-else class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> {{ $t('general.copy') }}</template>
<a-button type="text" class="!rounded-md" @click="copyToken(record.token)">
<template #icon>
<MdiContentCopy class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown
:trigger="['click']"
class="flex"
placement="bottomRight"
overlay-class-name="nc-dropdown-api-token-mgmt"
>
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert class="nc-token-menu" />
</div>
</a-button>
</div>
<template #overlay>
<a-menu data-testid="nc-token-row-action-icon">
<a-menu-item>
<div class="flex flex-row items-center py-3 h-[1rem] nc-delete-token" @click="deleteToken(record.token)">
<MdiDeleteOutline class="flex" />
<div class="text-xs pl-2">{{ $t('general.remove') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</a-table-column>
</a-table>
</div>
<a-modal
v-model:visible="showNewTokenModal"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-generate-token"
>
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" />
</template>
</a-button>
<!-- Generate Token -->
<div class="flex flex-row justify-center w-full -mt-1 mb-3">
<a-typography-title :level="5">{{ $t('title.generateToken') }}</a-typography-title>
</div>
<!-- Description -->
<a-form
ref="form"
:model="selectedTokenData"
name="basic"
layout="vertical"
class="flex flex-col justify-center space-y-6"
no-style
autocomplete="off"
@finish="generateToken"
>
<a-input
:ref="descriptionInput"
v-model:value="selectedTokenData.description"
data-testid="nc-token-modal-description"
:placeholder="$t('labels.description')"
/>
<!-- Generate -->
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit" data-testid="nc-token-modal-save">
{{ $t('general.generate') }}
</a-button>
</div>
</a-form>
</div>
</a-modal>
</div>
</template>
<style scoped></style>

283
packages/nc-gui/components/account/UserList.vue

@ -0,0 +1,283 @@
<script lang="ts" setup>
import { Modal, message } from 'ant-design-vue'
import type { RequestParams, UserType } from 'nocodb-sdk'
import { Role, extractSdkResponseErrorMsg, useApi, useCopy, useDashboard, useNuxtApp } from '#imports'
import type { User } from '~/lib'
const { api, isLoading } = useApi()
const { $e } = useNuxtApp()
const { t } = useI18n()
const { dashboardUrl } = $(useDashboard())
const { copy } = useCopy()
let users = $ref<UserType[]>([])
let currentPage = $ref(1)
const currentLimit = $ref(10)
const showUserModal = ref(false)
const userMadalKey = ref(0)
const searchText = ref<string>('')
const pagination = reactive({
total: 0,
pageSize: 10,
position: ['bottomCenter'],
})
const loadUsers = async (page = currentPage, limit = currentLimit) => {
currentPage = page
try {
const response: any = await api.orgUsers.list({
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
query: searchText.value,
},
} as RequestParams)
if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
users = response.list as UserType[]
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
loadUsers()
const updateRole = async (userId: string, roles: Role) => {
try {
await api.orgUsers.update(userId, {
roles,
} as unknown as UserType)
message.success(t('msg.success.roleUpdated'))
$e('a:org-user:role-updated', { role: roles })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const deleteUser = async (userId: string) => {
Modal.confirm({
title: 'Are you sure you want to delete this user?',
type: 'warn',
content: 'On deleting, user will remove from organization and any sync source(Airtable) created by user will get removed',
onOk: async () => {
try {
await api.orgUsers.delete(userId)
message.success(t('msg.success.userDeleted'))
await loadUsers()
$e('a:org-user:user-deleted')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
})
}
const resendInvite = async (user: User) => {
try {
await api.orgUsers.resendInvite(user.id)
// Invite email sent successfully
message.success(t('msg.success.inviteEmailSent'))
await loadUsers()
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:org-user:resend-invite')
}
const copyInviteUrl = (user: User) => {
if (!user.invite_token) return
copy(`${dashboardUrl}#/signup/${user.invite_token}`)
// Invite URL copied to clipboard
message.success(t('msg.success.inviteURLCopied'))
$e('c:user:copy-url')
}
const copyPasswordResetUrl = async (user: User) => {
try {
const { reset_password_url } = await api.orgUsers.generatePasswordResetToken(user.id)
copy(reset_password_url)
// Invite URL copied to clipboard
message.success(t('msg.success.passwordResetURLCopied'))
$e('c:user:copy-url')
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<div data-testid="nc-super-user-list">
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">User Management</div>
<div class="max-w-[900px] mx-auto p-4">
<div class="py-2 flex gap-4 items-center">
<a-input-search
v-model:value="searchText"
size="small"
class="max-w-[300px]"
placeholder="Filter by email"
@blur="loadUsers"
@keydown.enter="loadUsers"
>
</a-input-search>
<div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadUsers" />
<a-button
data-testid="nc-super-user-invite"
size="small"
type="primary"
@click="
() => {
showUserModal = true
userMadalKey++
}
"
>
<div class="flex items-center gap-1">
<MdiAdd />
Invite new user
</div>
</a-button>
</div>
<a-table
:row-key="(record) => record.id"
:data-source="users"
:pagination="pagination"
:loading="isLoading"
size="small"
@change="loadUsers($event.current)"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<!-- Email -->
<a-table-column key="email" :title="$t('labels.email')" data-index="email">
<template #default="{ text }">
<div>
{{ text }}
</div>
</template>
</a-table-column>
<!-- Role -->
<a-table-column key="roles" :title="$t('objects.role')" data-index="roles">
<template #default="{ record }">
<div>
<div v-if="record.roles.includes('super')" class="font-weight-bold">Super Admin</div>
<a-select
v-else
v-model:value="record.roles"
class="w-[220px] nc-user-roles"
:dropdown-match-select-width="false"
@change="updateRole(record.id, record.roles)"
>
<a-select-option
class="nc-users-list-role-option"
:value="Role.OrgLevelCreator"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
<a-select-option
class="nc-users-list-role-option"
:value="Role.OrgLevelViewer"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
</a-select>
</div>
</template>
</a-table-column>
<!-- &lt;!&ndash; Projects &ndash;&gt;
<a-table-column key="projectsCount" :title="$t('objects.projects')" data-index="projectsCount">
<template #default="{ text }">
<div>
{{ text }}
</div>
</template>
</a-table-column> -->
<!-- Actions -->
<a-table-column key="id" :title="$t('labels.actions')" data-index="id">
<template #default="{ text, record }">
<div v-if="!record.roles.includes('super')" class="flex items-center gap-2">
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<MdiDotsHorizontal class="nc-user-row-action" />
</div>
</a-button>
</div>
<template #overlay>
<a-menu>
<template v-if="record.invite_token">
<a-menu-item>
<!-- Resend invite Email -->
<div class="flex flex-row items-center py-3" @click="resendInvite(record)">
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.resendInvite') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(record)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div>
</div>
</a-menu-item>
</template>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyPasswordResetUrl(record)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyPasswordResetURL') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="deleteUser(text)">
<MdiDeleteOutline data-testid="nc-super-user-delete" class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('general.delete') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<span v-else></span>
</template>
</a-table-column>
</a-table>
<LazyAccountUsersModal :key="userMadalKey" :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" />
</div>
</div>
</template>

256
packages/nc-gui/components/account/UsersModal.vue

@ -0,0 +1,256 @@
<script setup lang="ts">
import type { UserType } from 'nocodb-sdk'
import {
Form,
computed,
extractSdkResponseErrorMsg,
message,
ref,
useCopy,
useDashboard,
useI18n,
useNuxtApp,
validateEmail,
} from '#imports'
import type { User } from '~/lib'
import { Role } from '~/lib'
interface Props {
show: boolean
selectedUser?: User
}
interface Users {
emails: string
role: Role.OrgLevelCreator | Role.OrgLevelViewer
invitationToken?: string
}
const { show } = defineProps<Props>()
const emit = defineEmits(['closed', 'reload'])
const { t } = useI18n()
const { $api, $e } = useNuxtApp()
const { copy } = useCopy()
const { dashboardUrl } = $(useDashboard())
const usersData = $ref<Users>({ emails: '', role: Role.OrgLevelViewer, invitationToken: undefined })
const formRef = ref()
const useForm = Form.useForm
const validators = computed(() => {
return {
emails: [
{
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => {
if (!value || value.length === 0) {
callback('Email is required')
return
}
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !validateEmail(e))
if (invalidEmails.length > 0) {
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `)
} else {
callback()
}
},
},
],
}
})
const { validateInfos } = useForm(usersData, validators)
const saveUser = async () => {
$e('a:org-user:invite', { role: usersData.role })
await formRef.value?.validateFields()
try {
// todo: update sdk(swagger.json)
const res = await $api.orgUsers.add({
roles: usersData.role,
email: usersData.emails,
} as unknown as UserType)
usersData.invitationToken = res.invite_token
emit('reload')
// Successfully updated the user details
message.success(t('msg.success.userAdded'))
} catch (e: any) {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
const inviteUrl = $computed(() => (usersData.invitationToken ? `${dashboardUrl}#/signup/${usersData.invitationToken}` : null))
const copyUrl = async () => {
if (!inviteUrl) return
await copy(inviteUrl)
// Copied shareable base url to clipboard!
message.success(t('msg.success.shareableURLCopied'))
$e('c:shared-base:copy-url')
}
const clickInviteMore = () => {
$e('c:user:invite-more')
usersData.invitationToken = undefined
usersData.role = Role.OrgLevelViewer
usersData.emails = ''
}
const emailInput = ref((el) => {
el?.focus()
})
</script>
<template>
<a-modal
:footer="null"
centered
:visible="show"
:closable="false"
width="max(50vw, 44rem)"
wrap-class-name="nc-modal-invite-user"
@cancel="emit('closed')"
>
<div class="flex flex-col">
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full">
<a-typography-title class="select-none" :level="4"> {{ $t('activity.inviteUser') }}</a-typography-title>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon>
<MaterialSymbolsCloseRounded data-testid="nc-root-user-invite-modal-close" class="flex mx-auto" />
</template>
</a-button>
</div>
<div class="px-2 mt-1.5">
<template v-if="usersData.invitationToken">
<div class="flex flex-col mt-1 border-b-1 pb-5">
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]">
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div>
</div>
<a-alert class="mt-1" type="success" show-icon>
<template #message>
<div class="flex flex-row justify-between items-center py-1">
<div class="flex pl-2 text-green-700 text-xs">
{{ inviteUrl }}
</div>
<a-button type="text" class="!rounded-md -mt-0.5" @click="copyUrl">
<template #icon>
<MdiContentCopy class="flex mx-auto text-green-700 h-[1rem]" />
</template>
</a-button>
</div>
</template>
</a-alert>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">
{{ $t('msg.info.userInviteNoSMTP') }}
{{ usersData.invitationToken && usersData.emails }}
</div>
<div class="flex flex-row justify-start mt-4 ml-2">
<a-button size="small" outlined @click="clickInviteMore">
<div class="flex flex-row justify-center items-center space-x-0.5">
<MaterialSymbolsSendOutline class="flex mx-auto text-gray-600 h-[0.8rem]" />
<div class="text-xs text-gray-600">{{ $t('activity.inviteMore') }}</div>
</div>
</a-button>
</div>
</div>
</template>
<div v-else class="flex flex-col pb-4">
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]">
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">{{ $t('activity.inviteUser') }}</div>
</div>
<div class="border-1 py-3 px-4 rounded-md mt-1">
<a-form
ref="formRef"
:validate-on-rule-change="false"
:model="usersData"
validate-trigger="onBlur"
@finish="saveUser"
>
<div class="flex flex-row space-x-4">
<div class="flex flex-col w-3/4">
<a-form-item
v-bind="validateInfos.emails"
validate-trigger="onBlur"
name="emails"
:rules="[{ required: true, message: 'Please input email' }]"
>
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div>
<a-input
:ref="emailInput"
v-model:value="usersData.emails"
validate-trigger="onBlur"
:placeholder="$t('labels.email')"
/>
</a-form-item>
</div>
<div class="flex flex-col w-2/4">
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]">
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('labels.selectUserRole') }}</div>
<a-select v-model:value="usersData.role" class="nc-user-roles" dropdown-class-name="nc-dropdown-user-role">
<a-select-option
class="nc-role-option"
:value="Role.OrgLevelCreator"
:label="$t(`objects.roleType.orgLevelCreator`)"
>
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</a-select-option>
<a-select-option
class="nc-role-option"
:value="Role.OrgLevelViewer"
:label="$t(`objects.roleType.orgLevelViewer`)"
>
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div>
<span class="text-gray-500 text-xs whitespace-normal">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</a-select-option>
</a-select>
</a-form-item>
</div>
</div>
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit">
<div class="flex flex-row justify-center items-center space-x-1.5">
<MaterialSymbolsSendOutline class="flex h-[0.8rem]" />
<div>{{ $t('activity.invite') }}</div>
</div>
</a-button>
</div>
</a-form>
</div>
</div>
</div>
</div>
</a-modal>
</template>

21
packages/nc-gui/components/cell/Checkbox.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject } from '#imports'
import { ActiveCellInj, ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject, useSelectedCellKeyupListener } from '#imports'
interface Props {
// If the previous cell value was a text, the initial checkbox value is a string type
@ -15,6 +15,8 @@ const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const active = inject(ActiveCellInj, ref(false))
let vModel = $computed({
get: () => !!props.modelValue && props.modelValue !== '0' && props.modelValue !== 0,
set: (val) => emits('update:modelValue', val),
@ -37,11 +39,20 @@ const checkboxMeta = $computed(() => {
}
})
function onClick() {
if (!readOnly?.value) {
function onClick(force?: boolean) {
if (!readOnly?.value && (force || active.value)) {
vModel = !vModel
}
}
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Enter':
onClick()
e.stopPropagation()
break
}
})
</script>
<template>
@ -53,7 +64,7 @@ function onClick() {
'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel,
}"
@click="onClick"
@click="onClick(false)"
>
<div class="px-1 pt-1 rounded-full items-center" :class="{ 'bg-gray-100': !vModel, '!ml-[-8px]': readOnly }">
<Transition name="layout" mode="out-in" :duration="100">
@ -62,6 +73,7 @@ function onClick() {
:style="{
color: checkboxMeta.color,
}"
@click.stop="onClick(true)"
/>
</Transition>
</div>
@ -72,6 +84,7 @@ function onClick() {
.nc-cell-hover-show {
opacity: 0;
transition: 0.3s opacity;
&:hover {
opacity: 0.7;
}

5
packages/nc-gui/components/cell/Currency.vue

@ -61,6 +61,11 @@ onMounted(() => {
v-model="vModel"
class="w-full h-full border-none outline-none px-2"
@blur="submitCurrency"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else-if="vModel">{{ currency }}</span>

42
packages/nc-gui/components/cell/DatePicker.vue

@ -1,12 +1,23 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ColumnInj, ReadonlyInj, computed, inject, ref, watch } from '#imports'
import {
ActiveCellInj,
ColumnInj,
EditModeInj,
ReadonlyInj,
computed,
inject,
ref,
useSelectedCellKeyupListener,
watch,
} from '#imports'
interface Props {
modelValue?: string | null
isPk?: boolean
}
const { modelValue } = defineProps<Props>()
const { modelValue, isPk } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -14,6 +25,10 @@ const columnMeta = inject(ColumnInj, null)!
const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isDateInvalid = $ref(false)
const dateFormat = $computed(() => columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD')
@ -56,6 +71,21 @@ watch(
)
const placeholder = computed(() => (isDateInvalid ? 'Invalid date' : ''))
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script>
<template>
@ -65,11 +95,11 @@ const placeholder = computed(() => (isDateInvalid ? 'Invalid date' : ''))
class="!w-full !px-0 !border-none"
:format="dateFormat"
:placeholder="placeholder"
:allow-clear="!readOnly"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:dropdown-class-name="`${randomClass} nc-picker-date`"
:open="readOnly ? false : open"
@click="open = !open"
:dropdown-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''}`"
:open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
@click="open = (active || editable) && !open"
>
<template #suffixIcon></template>
</a-date-picker>

34
packages/nc-gui/components/cell/DateTimePicker.vue

@ -1,12 +1,13 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ReadonlyInj, inject, ref, useProject, watch } from '#imports'
import { ActiveCellInj, ReadonlyInj, inject, ref, useProject, useSelectedCellKeyupListener, watch } from '#imports'
interface Props {
modelValue?: string | null
isPk?: boolean
}
const { modelValue } = defineProps<Props>()
const { modelValue, isPk } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -14,6 +15,10 @@ const { isMysql } = useProject()
const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isDateInvalid = $ref(false)
const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
@ -55,6 +60,21 @@ watch(
},
{ flush: 'post' },
)
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script>
<template>
@ -65,12 +85,12 @@ watch(
class="!w-full !px-0 !border-none"
format="YYYY-MM-DD HH:mm"
:placeholder="isDateInvalid ? 'Invalid date' : ''"
:allow-clear="!readOnly"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:dropdown-class-name="`${randomClass} nc-picker-datetime`"
:open="readOnly ? false : open"
:disabled="readOnly"
@click="open = !open"
:dropdown-class-name="`${randomClass} nc-picker-datetime ${open ? 'active' : ''}`"
:open="readOnly || (localState && isPk) ? false : open && (active || editable)"
:disabled="readOnly || (localState && isPk)"
@click="open = (active || editable) && !open"
@ok="open = !open"
>
<template #suffixIcon></template>

5
packages/nc-gui/components/cell/Decimal.vue

@ -30,6 +30,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
type="number"
step="0.1"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

5
packages/nc-gui/components/cell/Duration.vue

@ -84,6 +84,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
@blur="submitDuration"
@keypress="checkDurationFormat($event)"
@keydown.enter="submitDuration"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else> {{ localState }}</span>

17
packages/nc-gui/components/cell/Email.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, computed, inject, isEmail, useVModel } from '#imports'
import { EditModeInj, computed, inject, useVModel, validateEmail } from '#imports'
interface Props {
modelValue: string | null | undefined
@ -18,13 +18,24 @@ const editEnabled = inject(EditModeInj)
const vModel = useVModel(props, 'modelValue', emits)
const validEmail = computed(() => vModel.value && isEmail(vModel.value))
const validEmail = computed(() => vModel.value && validateEmail(vModel.value))
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script>
<template>
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none text-sm px-2" @blur="editEnabled = false" />
<input
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none text-sm px-2"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">
{{ vModel }}

5
packages/nc-gui/components/cell/Float.vue

@ -30,6 +30,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
type="number"
step="0.1"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

5
packages/nc-gui/components/cell/Integer.vue

@ -34,6 +34,11 @@ function onKeyDown(evt: KeyboardEvent) {
type="number"
@blur="editEnabled = false"
@keydown="onKeyDown"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else class="text-sm">{{ vModel }}</span>
</template>

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

@ -1,4 +1,5 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType, SelectOptionsType } from 'nocodb-sdk'
@ -8,12 +9,17 @@ import {
IsKanbanInj,
ReadonlyInj,
computed,
enumColor,
extractSdkResponseErrorMsg,
h,
inject,
onMounted,
reactive,
ref,
useEventListener,
useMetas,
useProject,
useSelectedCellKeyupListener,
watch,
} from '#imports'
import MdiCloseCircle from '~icons/mdi/close-circle'
@ -27,14 +33,16 @@ const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMysql } = useProject()
const column = inject(ColumnInj)!
const readOnly = inject(ReadonlyInj)!
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const selectedIds = ref<string[]>([])
const aselect = ref<typeof AntSelect>()
@ -43,7 +51,19 @@ const isOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const options = computed<SelectOptionType[]>(() => {
const searchVal = ref<string | null>()
const { $api } = useNuxtApp()
const { getMeta } = useMetas()
const { isPg, isMysql } = useProject()
// a variable to keep newly created options value
// temporary until it's add the option to column meta
const tempSelectedOptsState = reactive<string[]>([])
const options = computed<(SelectOptionType & { value?: string })[]>(() => {
if (column?.value.colOptions) {
const opts = column.value.colOptions
? (column.value.colOptions as SelectOptionsType).options.filter((el: SelectOptionType) => el.title !== '') || []
@ -51,21 +71,35 @@ const options = computed<SelectOptionType[]>(() => {
for (const op of opts.filter((el: SelectOptionType) => el.order === null)) {
op.title = op.title?.replace(/^'/, '').replace(/'$/, '')
}
return opts
return opts.map((o: SelectOptionType) => ({ ...o, value: o.title }))
}
return []
})
const isOptionMissing = computed(() => {
return (options.value ?? []).every((op) => op.title !== searchVal.value)
})
const vModel = computed({
get: () =>
selectedIds.value.reduce((acc, id) => {
const title = options.value.find((op) => op.id === id)?.title
get: () => {
const selected = selectedIds.value.reduce((acc, id) => {
const title = (options.value.find((op) => op.id === id) || options.value.find((op) => op.title === id))?.title
if (title) acc.push(title)
return acc
}, [] as string[]),
set: (val) => emit('update:modelValue', val.length === 0 ? null : val.join(',')),
}, [] as string[])
if (tempSelectedOptsState.length) selected.push(...tempSelectedOptsState)
return selected
},
set: (val) => {
if (isOptionMissing.value && val.length && val[val.length - 1] === searchVal.value) {
return addIfMissingAndSave()
}
emit('update:modelValue', val.length === 0 ? null : val.join(','))
},
})
const selectedTitles = computed(() =>
@ -85,18 +119,6 @@ const selectedTitles = computed(() =>
: [],
)
const handleKeys = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
e.preventDefault()
isOpen.value = false
break
case 'Enter':
e.stopPropagation()
break
}
}
const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
@ -105,9 +127,10 @@ const handleClose = (e: MouseEvent) => {
onMounted(() => {
selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el)?.id
if (item) {
return [item]
const item = options.value.find((op) => op.title === el)
const itemIdOrTitle = item?.id || item?.title
if (itemIdOrTitle) {
return [itemIdOrTitle]
}
return []
@ -119,10 +142,10 @@ useEventListener(document, 'click', handleClose)
watch(
() => modelValue,
() => {
selectedIds.value = selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el)?.id
if (item) {
return [item]
selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el)
if (item && (item.id || item.title)) {
return [(item.id || item.title)!]
}
return []
@ -131,31 +154,138 @@ watch(
)
watch(isOpen, (n, _o) => {
if (!n) aselect.value?.$el.blur()
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus()
}
})
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Escape':
isOpen.value = false
break
case 'Enter':
if (active.value && !isOpen.value) {
isOpen.value = true
}
break
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowLeft':
case 'Delete':
// skip
break
default:
// toggle only if char key pressed
if (e.key?.length === 1) {
e.stopPropagation()
isOpen.value = true
}
break
}
})
const activeOptCreateInProgress = ref(0)
async function addIfMissingAndSave() {
if (!searchVal.value || isPublic.value) return false
try {
tempSelectedOptsState.push(searchVal.value)
const newOptValue = searchVal?.value
searchVal.value = ''
activeOptCreateInProgress.value++
if (newOptValue && !options.value.some((o) => o.title === newOptValue)) {
const newOptions = [...options.value]
newOptions.push({
title: newOptValue,
value: newOptValue,
color: enumColor.light[(options.value.length + 1) % enumColor.light.length],
})
column.value.colOptions = { options: newOptions.map(({ value: _, ...rest }) => rest) }
const updatedColMeta = { ...column.value }
// todo: refactor and avoid repetition
if (updatedColMeta.cdf) {
// Postgres returns default value wrapped with single quotes & casted with type so we have to get value between single quotes to keep it unified for all databases
if (isPg.value) {
updatedColMeta.cdf = updatedColMeta.cdf.substring(
updatedColMeta.cdf.indexOf(`'`) + 1,
updatedColMeta.cdf.lastIndexOf(`'`),
)
}
// Mysql escapes single quotes with backslash so we keep quotes but others have to unescaped
if (!isMysql.value) {
updatedColMeta.cdf = updatedColMeta.cdf.replace(/''/g, "'")
}
}
await $api.dbTableColumn.update(
(column.value as { fk_column_id?: string })?.fk_column_id || (column.value?.id as string),
updatedColMeta,
)
activeOptCreateInProgress.value--
if (!activeOptCreateInProgress.value) {
await getMeta(column.value.fk_model_id!, true)
vModel.value = [...vModel.value]
tempSelectedOptsState.splice(0, tempSelectedOptsState.length)
}
} else {
activeOptCreateInProgress.value--
}
} catch (e) {
// todo: handle error
console.log(e)
activeOptCreateInProgress.value--
message.error(await extractSdkResponseErrorMsg(e))
}
}
const search = () => {
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value
}
const onTagClick = (e: Event, onClose: Function) => {
// check clicked element is remove icon
if (
(e.target as HTMLElement)?.classList.contains('ant-tag-close-icon') ||
(e.target as HTMLElement)?.closest('.ant-tag-close-icon')
) {
e.stopPropagation()
onClose()
}
}
</script>
<template>
<a-select
ref="aselect"
v-model:value="vModel"
v-model:open="isOpen"
mode="multiple"
class="w-full"
:bordered="false"
clear-icon
:show-arrow="!readOnly"
:show-search="false"
:open="isOpen"
:show-search="active || editable"
:open="isOpen && (active || editable)"
:disabled="readOnly"
:class="{ '!ml-[-8px]': readOnly }"
dropdown-class-name="nc-dropdown-multi-select-cell"
@keydown="handleKeys"
@click="isOpen = !isOpen"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
@search="search"
@keydown.stop
@click="isOpen = (active || editable) && !isOpen"
>
<a-select-option
v-for="op of options"
:key="op.id"
:key="op.id || op.title"
:value="op.title"
:data-nc="`select-option-${column.title}-${rowIndex}`"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
@ -173,14 +303,24 @@ watch(isOpen, (n, _o) => {
</a-tag>
</a-select-option>
<a-select-option v-if="searchVal && isOptionMissing && !isPublic" :key="searchVal" :value="searchVal">
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</a-select-option>
<template #tagRender="{ value: val, onClose }">
<a-tag
v-if="options.find((el) => el.title === val)"
class="rounded-tag"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="active && (vModel.length > 1 || !column?.rqd)"
:closable="(active || editable) && (vModel.length > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
>
<span
@ -221,18 +361,23 @@ watch(isOpen, (n, _o) => {
margin-right: -6px;
margin-left: 3px;
}
.ms-close-icon:before {
display: block;
}
.ms-close-icon:hover {
color: rgba(0, 0, 0, 0.45);
}
.rounded-tag {
@apply py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag" my-[2px];
}
:deep(.ant-tag-close-icon) {
@apply "text-slate-500";
}
@ -241,6 +386,3 @@ watch(isOpen, (n, _o) => {
@apply "flex overflow-hidden";
}
</style>
<!--
-->

12
packages/nc-gui/components/cell/Percent.vue

@ -1,4 +1,5 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, inject, useVModel } from '#imports'
interface Props {
@ -12,15 +13,26 @@ const emits = defineEmits(['update:modelValue'])
const editEnabled = inject(EditModeInj)
const vModel = useVModel(props, 'modelValue', emits)
const focus: VNodeRef = (el) => {
;(el as HTMLInputElement)?.focus()
}
</script>
<template>
<input
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="w-full !border-none text-base"
:class="{ '!px-2': editEnabled }"
type="number"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else>{{ vModel }}</span>
</template>

19
packages/nc-gui/components/cell/Rating.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ColumnInj, EditModeInj, computed, inject } from '#imports'
import { ActiveCellInj, ColumnInj, computed, inject, useSelectedCellKeyupListener } from '#imports'
interface Props {
modelValue?: number | null | undefined
@ -11,8 +11,6 @@ const emits = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)!
const editEnabled = inject(EditModeInj)
const ratingMeta = computed(() => {
return {
icon: {
@ -29,16 +27,17 @@ const vModel = computed({
get: () => modelValue ?? NaN,
set: (val) => emits('update:modelValue', val),
})
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
if (/^\d$/.test(e.key)) {
e.stopPropagation()
vModel.value = +e.key === +vModel.value ? 0 : +e.key
}
})
</script>
<template>
<a-rate
v-model:value="vModel"
:count="ratingMeta.max"
:style="`color: ${ratingMeta.color}; padding: 0px 5px`"
:class="{ '!ml-[-8px]': !editEnabled }"
:disabled="!editEnabled"
>
<a-rate v-model:value="vModel" :count="ratingMeta.max" :style="`color: ${ratingMeta.color}; padding: 0px 5px`">
<template #character>
<MdiStar v-if="ratingMeta.icon.full === 'mdi-star'" class="text-sm" />
<MdiHeart v-if="ratingMeta.icon.full === 'mdi-heart'" class="text-sm" />

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

@ -1,8 +1,22 @@
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import type { SelectOptionType } from 'nocodb-sdk'
import { ActiveCellInj, ColumnInj, IsKanbanInj, ReadonlyInj, computed, inject, ref, useEventListener, watch } from '#imports'
import {
ActiveCellInj,
ColumnInj,
EditModeInj,
IsKanbanInj,
ReadonlyInj,
computed,
enumColor,
extractSdkResponseErrorMsg,
inject,
ref,
useSelectedCellKeyupListener,
watch,
} from '#imports'
interface Props {
modelValue?: string | undefined
@ -19,18 +33,29 @@ const readOnly = inject(ReadonlyInj)!
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const vModel = computed({
get: () => modelValue,
set: (val) => emit('update:modelValue', val || null),
})
const isPublic = inject(IsPublicInj, ref(false))
const { $api } = useNuxtApp()
const searchVal = ref()
const options = computed<SelectOptionType[]>(() => {
const { getMeta } = useMetas()
const { isPg, isMysql } = useProject()
// a variable to keep newly created option value
// temporary until it's add the option to column meta
const tempSelectedOptState = ref<string>()
const options = computed<(SelectOptionType & { value: string })[]>(() => {
if (column?.value.colOptions) {
const opts = column.value.colOptions
? // todo: fix colOptions type, options does not exist as a property
@ -39,32 +64,118 @@ const options = computed<SelectOptionType[]>(() => {
for (const op of opts.filter((el: any) => el.order === null)) {
op.title = op.title.replace(/^'/, '').replace(/'$/, '')
}
return opts
return opts.map((o: any) => ({ ...o, value: o.title }))
}
return []
})
const handleKeys = (e: KeyboardEvent) => {
const isOptionMissing = computed(() => {
return (options.value ?? []).every((op) => op.title !== searchVal.value)
})
const vModel = computed({
get: () => tempSelectedOptState.value ?? modelValue,
set: (val) => {
if (isOptionMissing.value && val === searchVal.value) {
tempSelectedOptState.value = val
return addIfMissingAndSave().finally(() => {
tempSelectedOptState.value = undefined
})
}
emit('update:modelValue', val || null)
},
})
watch(isOpen, (n, _o) => {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus()
}
})
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Escape':
e.preventDefault()
isOpen.value = false
break
case 'Enter':
if (active.value && !isOpen.value) {
isOpen.value = true
}
break
default:
// toggle only if char key pressed
if (e.key?.length === 1) {
e.stopPropagation()
isOpen.value = true
}
break
}
}
})
const handleClose = (e: MouseEvent) => {
if (aselect.value && !aselect.value.$el.contains(e.target)) {
isOpen.value = false
aselect.value.blur()
async function addIfMissingAndSave() {
if (!searchVal.value || isPublic.value) return false
const newOptValue = searchVal.value
searchVal.value = ''
if (newOptValue && !options.value.some((o) => o.title === newOptValue)) {
try {
options.value.push({
title: newOptValue,
value: newOptValue,
color: enumColor.light[(options.value.length + 1) % enumColor.light.length],
})
column.value.colOptions = { options: options.value.map(({ value: _, ...rest }) => rest) }
const updatedColMeta = { ...column.value }
// todo: refactor and avoid repetition
if (updatedColMeta.cdf) {
// Postgres returns default value wrapped with single quotes & casted with type so we have to get value between single quotes to keep it unified for all databases
if (isPg.value) {
updatedColMeta.cdf = updatedColMeta.cdf.substring(
updatedColMeta.cdf.indexOf(`'`) + 1,
updatedColMeta.cdf.lastIndexOf(`'`),
)
}
// Mysql escapes single quotes with backslash so we keep quotes but others have to unescaped
if (!isMysql.value) {
updatedColMeta.cdf = updatedColMeta.cdf.replace(/''/g, "'")
}
}
await $api.dbTableColumn.update(
(column.value as { fk_column_id?: string })?.fk_column_id || (column.value?.id as string),
updatedColMeta,
)
vModel.value = newOptValue
await getMeta(column.value.fk_model_id!, true)
} catch (e) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
}
useEventListener(document, 'click', handleClose)
const search = () => {
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value
}
watch(isOpen, (n, _o) => {
if (!n) aselect.value?.$el.blur()
})
const toggleMenu = (e: Event) => {
// todo: refactor
// check clicked element is clear icon
if (
(e.target as HTMLElement)?.classList.contains('ant-select-clear') ||
(e.target as HTMLElement)?.closest('.ant-select-clear')
) {
vModel.value = ''
return
}
isOpen.value = (active.value || editable.value) && !isOpen.value
}
</script>
<template>
@ -74,19 +185,21 @@ watch(isOpen, (n, _o) => {
class="w-full"
:allow-clear="!column.rqd && active"
:bordered="false"
:open="isOpen"
:open="isOpen && (active || editable)"
:disabled="readOnly"
:show-arrow="!readOnly && (active || vModel === null)"
dropdown-class-name="nc-dropdown-single-select-cell"
:show-arrow="!readOnly && (active || editable || vModel === null)"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen ? 'active' : ''}`"
:show-search="active || editable"
@select="isOpen = false"
@keydown="handleKeys"
@click="isOpen = !isOpen"
@keydown.stop
@search="search"
@click="toggleMenu"
>
<a-select-option
v-for="op of options"
:key="op.title"
:value="op.title"
:data-nc="`select-option-${column.title}-${rowIndex}`"
:data-testid="`select-option-${column.title}-${rowIndex}`"
@click.stop
>
<a-tag class="rounded-tag" :color="op.color">
@ -103,6 +216,15 @@ watch(isOpen, (n, _o) => {
</span>
</a-tag>
</a-select-option>
<a-select-option v-if="searchVal && isOptionMissing && !isPublic" :key="searchVal" :value="searchVal">
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
</div>
</a-select-option>
</a-select>
</template>
@ -110,13 +232,12 @@ watch(isOpen, (n, _o) => {
.rounded-tag {
@apply py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag";
}
:deep(.ant-select-clear) {
opacity: 1;
}
</style>
<!--
-->

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

@ -16,7 +16,9 @@ const readonly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
const focus: VNodeRef = (el) => {
;(el as HTMLInputElement)?.focus()
}
</script>
<template>
@ -27,6 +29,11 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
class="h-full w-full outline-none bg-transparent"
:class="{ '!px-2': editEnabled }"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else>{{ vModel }}</span>

5
packages/nc-gui/components/cell/TextArea.vue

@ -26,6 +26,11 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
@blur="editEnabled = false"
@keydown.alt.enter.stop
@keydown.shift.enter.stop
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<span v-else>{{ vModel }}</span>

32
packages/nc-gui/components/cell/TimePicker.vue

@ -1,12 +1,13 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ReadonlyInj, inject, onClickOutside, useProject, watch } from '#imports'
import { ActiveCellInj, ReadonlyInj, inject, onClickOutside, useProject, useSelectedCellKeyupListener, watch } from '#imports'
interface Props {
modelValue?: string | null | undefined
isPk?: boolean
}
const { modelValue } = defineProps<Props>()
const { modelValue, isPk } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -14,6 +15,10 @@ const { isMysql } = useProject()
const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isTimeInvalid = $ref(false)
const dateFormat = isMysql.value ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
@ -64,6 +69,21 @@ watch(
},
{ flush: 'post' },
)
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script>
<template>
@ -76,11 +96,11 @@ watch(
format="HH:mm"
class="!w-full !px-0 !border-none"
:placeholder="isTimeInvalid ? 'Invalid time' : ''"
:allow-clear="!readOnly"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:open="readOnly ? false : open"
:popup-class-name="`${randomClass} nc-picker-time`"
@click="open = !open"
:open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
:popup-class-name="`${randomClass} nc-picker-time ${open ? 'active' : ''}`"
@click="open = (active || editable) && !open"
@ok="open = !open"
>
<template #suffixIcon></template>

5
packages/nc-gui/components/cell/Url.vue

@ -79,6 +79,11 @@ watch(
v-model="vModel"
class="outline-none text-sm w-full px-2"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
/>
<nuxt-link

34
packages/nc-gui/components/cell/YearPicker.vue

@ -1,17 +1,22 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { ReadonlyInj, computed, inject, onClickOutside, ref, watch } from '#imports'
import { ActiveCellInj, ReadonlyInj, computed, inject, onClickOutside, ref, useSelectedCellKeyupListener, watch } from '#imports'
interface Props {
modelValue?: number | string | null
isPk?: boolean
}
const { modelValue } = defineProps<Props>()
const { modelValue, isPk = false } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const readOnly = inject(ReadonlyInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
let isYearInvalid = $ref(false)
const localState = $computed({
@ -54,6 +59,21 @@ watch(
)
const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : ''))
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
}
break
}
})
</script>
<template>
@ -63,12 +83,12 @@ const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : ''))
:bordered="false"
class="!w-full !px-0 !border-none"
:placeholder="placeholder"
:allow-clear="!readOnly"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:open="readOnly ? false : open"
:dropdown-class-name="`${randomClass} nc-picker-year`"
@click="open = !open"
@change="open = !open"
:open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
:dropdown-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''}`"
@click="open = (active || editable) && !open"
@change="open = (active || editable) && !open"
>
<template #suffixIcon></template>
</a-date-picker>

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

@ -3,6 +3,7 @@ import { onKeyDown } from '@vueuse/core'
import { useProvideAttachmentCell } from './utils'
import { useSortable } from './sort'
import {
ActiveCellInj,
DropZoneRef,
IsGalleryInj,
IsKanbanInj,
@ -12,6 +13,7 @@ import {
openLink,
ref,
useDropZone,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useSmartsheetStoreOrThrow,
watch,
@ -113,9 +115,17 @@ watch(
attachments.value = []
}
}
} else {
if (isPublic.value && isForm.value) {
storedFiles.value = []
} else {
attachments.value = []
}
}
},
{ immediate: true },
{
immediate: true,
},
)
/** updates attachments array for autosave */
@ -136,6 +146,13 @@ watch(
rowState.value[column.value!.title!] = storedFiles.value
},
)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
if (e.key === 'Enter' && !isReadonly.value) {
e.stopPropagation()
modalVisible.value = true
}
})
</script>
<template>
@ -161,7 +178,7 @@ watch(
v-if="!isReadonly"
: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)"
data-nc="attachment-cell-file-picker-button"
data-testid="attachment-cell-file-picker-button"
@click.stop="open"
>
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />

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

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

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

@ -112,7 +112,7 @@ const columns = [
:columns="columns"
:pagination="false"
:loading="isLoading"
data-nc="audit-tab-table"
data-testid="audit-tab-table"
>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />

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

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

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Form, computed, onMounted, ref, useProject, useTable, useTabs, useVModel, validateTableName } from '#imports'
import { Form, computed, nextTick, onMounted, ref, useProject, useTable, useTabs, useVModel, validateTableName } from '#imports'
import { TabType } from '~/lib'
const props = defineProps<{
@ -32,13 +32,21 @@ const { table, createTable, generateUniqueTitle, tables, project } = useTable(as
const useForm = Form.useForm
const validateDuplicateAlias = (v: string) => (tables.value || []).every((t) => t.title !== (v || '')) || 'Duplicate table alias'
const validators = computed(() => {
return {
title: [
validateTableName,
validateDuplicateAlias,
{
validator: (_: any, value: any) => {
// validate duplicate alias
return new Promise((resolve, reject) => {
if ((tables.value || []).some((t) => t.title === (value || ''))) {
return reject(new Error('Duplicate table alias'))
}
return resolve(true)
})
},
},
{
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
@ -62,17 +70,29 @@ const validators = computed(() => {
table_name: [validateTableName],
}
})
const { validateInfos } = useForm(table, validators)
const { validate, validateInfos } = useForm(table, validators)
const systemColumnsCheckboxInfo = SYSTEM_COLUMNS.map((c, index) => ({
value: c,
disabled: index === 0,
}))
const _createTable = async () => {
try {
await validate()
} catch (e: any) {
e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(',')))
if (e.errorFields.length) return
}
await createTable()
}
onMounted(() => {
generateUniqueTitle()
inputEl.value?.focus()
nextTick(() => {
inputEl.value?.focus()
inputEl.value?.select()
})
})
</script>
@ -87,11 +107,11 @@ onMounted(() => {
<template #footer>
<a-button key="back" size="large" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" size="large" type="primary" @click="createTable()">{{ $t('general.submit') }}</a-button>
<a-button key="submit" size="large" type="primary" @click="_createTable">{{ $t('general.submit') }}</a-button>
</template>
<div class="pl-10 pr-10 pt-5">
<a-form :model="table" name="create-new-table-form" @keydown.enter="createTable">
<a-form :model="table" name="create-new-table-form" @keydown.enter="_createTable">
<!-- Create A New Table -->
<div class="prose-xl font-bold self-center my-4">{{ $t('activity.createTable') }}</div>
@ -105,7 +125,7 @@ onMounted(() => {
v-model:value="table.title"
size="large"
hide-details
data-nc="create-table-title-input"
data-testid="create-table-title-input"
:placeholder="$t('msg.info.enterTableName')"
/>
</a-form-item>

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

@ -119,6 +119,7 @@ const renameTable = async () => {
await $api.dbTable.update(tableMeta.id as string, {
project_id: tableMeta.project_id,
table_name: formState.title,
title: formState.title,
})
dialogShow.value = false

2
packages/nc-gui/components/dlg/ViewCreate.vue

@ -184,7 +184,7 @@ async function onSubmit() {
<template>
<a-modal v-model:visible="vModel" class="!top-[35%]" :confirm-loading="loading" wrap-class-name="nc-modal-view-create">
<template #title>
{{ $t(`general.${selectedViewId ? 'duplicate' : 'create'}`) }} <span class="text-capitalize">{{ typeAlias }}</span>
{{ $t(`general.${selectedViewId ? 'duplicate' : 'create'}`) }} <span class="capitalize">{{ typeAlias }}</span>
{{ $t('objects.view') }}
</template>

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

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

19
packages/nc-gui/components/general/language/Menu.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { Language } from '~/lib'
import { onMounted, useGlobal, useI18n, useNuxtApp } from '#imports'
import { useGlobal, useI18n, useNuxtApp } from '#imports'
import { setI18nLanguage } from '~/plugins/a.i18n'
const { $e } = useNuxtApp()
@ -11,31 +11,14 @@ const { locale } = useI18n()
const languages = $computed(() => Object.entries(Language).sort() as [keyof typeof Language, Language][])
const isRtlLang = $computed(() => ['fa', 'ar'].includes(currentLang.value))
function applyDirection() {
const targetDirection = isRtlLang ? 'rtl' : 'ltr'
const oppositeDirection = targetDirection === 'ltr' ? 'rtl' : 'ltr'
document.body.classList.remove(oppositeDirection)
document.body.classList.add(targetDirection)
document.body.style.direction = targetDirection
}
async function changeLanguage(lang: string) {
const nextLang = lang as keyof typeof Language
await setI18nLanguage(nextLang)
currentLang.value = nextLang
applyDirection()
$e('c:navbar:lang', { lang })
}
onMounted(() => {
applyDirection()
})
</script>
<template>

6
packages/nc-gui/components/shared-view/AskPassword.vue

@ -1,4 +1,6 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import type { InputPassword } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, message, ref, useRoute, useSharedView, useVModel } from '#imports'
const props = defineProps<{
@ -24,6 +26,8 @@ const onFinish = async () => {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const focus: VNodeRef = (el: typeof InputPassword) => el?.$el?.querySelector('input').focus()
</script>
<template>
@ -42,7 +46,7 @@ const onFinish = async () => {
<a-form ref="formRef" :model="formState" class="mt-2" @finish="onFinish">
<a-form-item name="password" :rules="[{ required: true, message: 'Password is required' }]">
<a-input-password v-model:value="formState.password" placeholder="Enter password" />
<a-input-password :ref="focus" v-model:value="formState.password" placeholder="Enter password" />
</a-form-item>
<a-button type="primary" html-type="submit">Unlock</a-button>

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

@ -103,9 +103,9 @@ const activeLang = $computed(() => langs.find((lang) => lang.name === selectedLa
const code = $computed(() => {
if (activeLang?.name === 'nocodb-sdk') {
return `${selectedClient === 'node' ? 'const { Api } require("nocodb-sdk");' : 'import { Api } from "nocodb-sdk";'}
return `${selectedClient === 'node' ? 'const { Api } = require("nocodb-sdk");' : 'import { Api } from "nocodb-sdk";'}
const api = new Api({
baseURL: ${JSON.stringify(apiUrl)},
baseURL: "${(appInfo && appInfo.ncSiteUrl) || '/'}",
headers: {
"xc-auth": ${JSON.stringify(token as string)}
}

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

@ -10,11 +10,36 @@ import {
ReadonlyInj,
computed,
inject,
isAttachment,
isAutoSaved,
isBoolean,
isCurrency,
isDate,
isDateTime,
isDecimal,
isDuration,
isEmail,
isFloat,
isInt,
isJSON,
isManualSaved,
isMultiSelect,
isPercent,
isPhoneNumber,
isPrimary,
isPrimaryKey,
isRating,
isSingleSelect,
isString,
isTextArea,
isTime,
isURL,
isYear,
provide,
ref,
toRef,
useColumn,
useDebounceFn,
useProject,
useSmartsheetRowStoreOrThrow,
useVModel,
} from '#imports'
@ -46,9 +71,7 @@ provide(EditModeInj, useVModel(props, 'editEnabled', emit))
provide(ActiveCellInj, active)
if (readOnly?.value) {
provide(ReadonlyInj, readOnly)
}
provide(ReadonlyInj, readOnly)
const isForm = inject(IsFormInj, ref(false))
@ -58,6 +81,10 @@ const isLocked = inject(IsLockedInj, ref(false))
const { currentRow } = useSmartsheetRowStoreOrThrow()
const { sqlUi } = useProject()
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))
const syncValue = useDebounceFn(
() => {
currentRow.value.rowMeta.changed = false
@ -66,32 +93,6 @@ const syncValue = useDebounceFn(
500,
{ maxWait: 2000 },
)
const {
isPrimary,
isURL,
isEmail,
isJSON,
isDate,
isYear,
isDateTime,
isTime,
isBoolean,
isDuration,
isRating,
isCurrency,
isAttachment,
isTextArea,
isString,
isInt,
isFloat,
isDecimal,
isSingleSelect,
isMultiSelect,
isPercent,
isPhoneNumber,
isAutoSaved,
isManualSaved,
} = useColumn(column)
const vModel = computed({
get: () => props.modelValue,
@ -99,9 +100,9 @@ const vModel = computed({
if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true
emit('update:modelValue', val)
if (isAutoSaved.value) {
if (isAutoSaved(column.value)) {
syncValue()
} else if (!isManualSaved.value) {
} else if (!isManualSaved(column.value)) {
emit('save')
currentRow.value.rowMeta.changed = true
}
@ -110,7 +111,7 @@ const vModel = computed({
})
const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
if (isJSON.value) return
if (isJSON(column.value)) return
if (currentRow.value.rowMeta.changed || currentRow.value.rowMeta.new) {
emit('save')
@ -125,32 +126,41 @@ const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
<template>
<div
class="nc-cell w-full"
:class="[`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, { 'text-blue-600': isPrimary && !virtual && !isForm }]"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ 'text-blue-600': isPrimary(column) && !virtual && !isForm },
]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
>
<LazyCellTextArea v-if="isTextArea" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect v-else-if="isSingleSelect" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellMultiSelect v-else-if="isMultiSelect" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDatePicker v-else-if="isDate" v-model="vModel" />
<LazyCellYearPicker v-else-if="isYear" v-model="vModel" />
<LazyCellDateTimePicker v-else-if="isDateTime" v-model="vModel" />
<LazyCellTimePicker v-else-if="isTime" v-model="vModel" />
<LazyCellRating v-else-if="isRating" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail" v-model="vModel" />
<LazyCellUrl v-else-if="isURL" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency" v-model="vModel" @save="emit('save')" />
<LazyCellDecimal v-else-if="isDecimal" v-model="vModel" />
<LazyCellInteger v-else-if="isInt" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat" v-model="vModel" />
<LazyCellText v-else-if="isString" v-model="vModel" />
<LazyCellJson v-else-if="isJSON" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div v-if="(isLocked || (isPublic && readOnly && !isForm)) && !isAttachment" class="nc-locked-overlay" @click.stop.prevent />
<template v-if="column">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellSingleSelect v-else-if="isSingleSelect(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellMultiSelect v-else-if="isMultiSelect(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker v-else-if="isDateTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" />
<LazyCellUrl v-else-if="isURL(column)" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellText v-else v-model="vModel" />
<div
v-if="(isLocked || (isPublic && readOnly && !isForm)) && !isAttachment(column)"
class="nc-locked-overlay"
@click.stop.prevent
/>
</template>
</div>
</template>

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

@ -387,7 +387,7 @@ watch(view, (nextView) => {
</script>
<template>
<a-row v-if="submitted" class="h-full" data-nc="nc-form-wrapper-submit">
<a-row v-if="submitted" class="h-full" data-testid="nc-form-wrapper-submit">
<a-col :span="24">
<div v-if="formViewData" class="items-center justify-center text-center mt-2">
<a-alert type="success">
@ -407,7 +407,7 @@ watch(view, (nextView) => {
</a-col>
</a-row>
<a-row v-else class="h-full flex" data-nc="nc-form-wrapper">
<a-row v-else class="h-full flex" data-testid="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">
<div class="flex flex-wrap gap-2">
<div class="flex-1 text-lg">
@ -419,7 +419,7 @@ watch(view, (nextView) => {
v-if="hiddenColumns.length"
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"
data-nc="nc-form-add-all"
data-testid="nc-form-add-all"
@click="addAllColumns"
>
<!-- Add all -->
@ -430,7 +430,7 @@ watch(view, (nextView) => {
v-if="localColumns.length"
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"
data-nc="nc-form-remove-all"
data-testid="nc-form-remove-all"
@click="removeAllColumns"
>
<!-- Remove all -->
@ -452,7 +452,7 @@ watch(view, (nextView) => {
<a-card
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"
:data-nc="`nc-form-hidden-column-${element.label || element.title}`"
:data-testid="`nc-form-hidden-column-${element.label || element.title}`"
@mousedown="moved = false"
@mousemove="moved = false"
@mouseup="handleMouseUp(element, index)"
@ -480,7 +480,7 @@ watch(view, (nextView) => {
<template #footer>
<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"
data-nc="nc-drag-n-drop-to-hide"
data-testid="nc-drag-n-drop-to-hide"
>
<!-- Drag and drop fields here to hide -->
{{ $t('msg.info.dragDropHide') }}
@ -538,7 +538,7 @@ watch(view, (nextView) => {
hide-details
placeholder="Form Title"
:bordered="false"
data-nc="nc-form-heading"
data-testid="nc-form-heading"
@blur="updateView"
@keydown.enter="updateView"
/>
@ -558,7 +558,7 @@ watch(view, (nextView) => {
:placeholder="$t('msg.info.formDesc')"
:bordered="false"
:disabled="!isEditable"
data-nc="nc-form-sub-heading"
data-testid="nc-form-sub-heading"
@blur="updateView"
@click="updateView"
/>
@ -588,7 +588,7 @@ watch(view, (nextView) => {
'bg-primary bg-opacity-5 ring-0.5 ring-accent ring-opacity-100': activeRow === element.title,
},
]"
data-nc="nc-form-fields"
data-testid="nc-form-fields"
@click="activeRow = element.title"
>
<div
@ -597,7 +597,7 @@ watch(view, (nextView) => {
>
<MdiEyeOffOutline
class="opacity-0 nc-field-remove-icon"
data-nc="nc-field-remove-icon"
data-testid="nc-field-remove-icon"
@click.stop="hideColumn(index)"
/>
</div>
@ -606,7 +606,7 @@ watch(view, (nextView) => {
<div class="flex gap-2 items-center">
<span
class="text-gray-500 mr-2 nc-form-input-required"
data-nc="nc-form-input-required"
data-testid="nc-form-input-required"
@click="
() => {
element.required = !element.required
@ -630,7 +630,7 @@ watch(view, (nextView) => {
v-model:value="element.label"
type="text"
class="form-meta-input nc-form-input-label"
data-nc="nc-form-input-label"
data-testid="nc-form-input-label"
:placeholder="$t('msg.info.formInput')"
@change="updateColMeta(element)"
>
@ -642,7 +642,7 @@ watch(view, (nextView) => {
v-model:value="element.description"
type="text"
class="form-meta-input text-sm nc-form-input-help-text"
data-nc="nc-form-input-help-text"
data-testid="nc-form-input-help-text"
:placeholder="$t('msg.info.formHelpText')"
@change="updateColMeta(element)"
/>
@ -655,7 +655,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
data-nc="nc-form-input-label"
data-testid="nc-form-input-label"
/>
<LazySmartsheetHeaderCell
@ -663,7 +663,7 @@ watch(view, (nextView) => {
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
data-nc="nc-form-input-label"
data-testid="nc-form-input-label"
/>
</div>
@ -678,7 +678,7 @@ watch(view, (nextView) => {
:row="row"
class="nc-input"
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-nc="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element"
@click.stop.prevent
/>
@ -694,14 +694,14 @@ watch(view, (nextView) => {
v-model="formState[element.title]"
class="nc-input"
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-nc="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element"
:edit-enabled="true"
@click.stop.prevent
/>
</a-form-item>
<div class="text-gray-500 text-xs" data-nc="nc-form-input-help-text-label">{{ element.description }}</div>
<div class="text-gray-500 text-xs" data-testid="nc-form-input-help-text-label">{{ element.description }}</div>
</div>
</template>
@ -716,7 +716,7 @@ watch(view, (nextView) => {
</Draggable>
<div class="justify-center flex mt-6">
<button type="submit" class="uppercase scaling-btn nc-form-submit" data-nc="nc-form-submit" @click="submitForm">
<button type="submit" class="uppercase scaling-btn nc-form-submit" data-testid="nc-form-submit" @click="submitForm">
{{ $t('general.submit') }}
</button>
</div>
@ -738,7 +738,7 @@ watch(view, (nextView) => {
:rows="3"
hide-details
class="nc-form-after-submit-msg"
data-nc="nc-form-after-submit-msg"
data-testid="nc-form-after-submit-msg"
@change="updateView"
/>
@ -751,7 +751,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:submit-another-form`]"
size="small"
class="nc-form-checkbox-submit-another-form"
data-nc="nc-form-checkbox-submit-another-form"
data-testid="nc-form-checkbox-submit-another-form"
@change="updateView"
/>
<span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span>
@ -764,7 +764,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:show-blank-form`]"
size="small"
class="nc-form-checkbox-show-blank-form"
data-nc="nc-form-checkbox-show-blank-form"
data-testid="nc-form-checkbox-show-blank-form"
@change="updateView"
/>
@ -777,7 +777,7 @@ watch(view, (nextView) => {
v-e="[`a:form-view:email-me`]"
size="small"
class="nc-form-checkbox-send-email"
data-nc="nc-form-checkbox-send-email"
data-testid="nc-form-checkbox-send-email"
@change="onEmailChange"
/>

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

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

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

@ -22,6 +22,7 @@ import {
extractPkFromRow,
inject,
isColumnRequiredAndNull,
isMac,
message,
onBeforeUnmount,
onClickOutside,
@ -168,52 +169,118 @@ const { selectCell, selectBlock, selectedRange, clearRangeRows, startSelectRange
isPkAvail,
clearCell,
makeEditable,
(row?: number | null, col?: number | null) => {
row = row ?? selected.row
col = col ?? selected.col
if (row !== undefined && col !== undefined && row !== null && col !== null) {
// get active cell
const rows = tbodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td')
const td = cols?.[col === 0 ? 0 : col + 1]
if (!td || !gridWrapper.value) return
const { height: headerHeight } = tableHead.value!.getBoundingClientRect()
const tdScroll = getContainerScrollForElement(td, gridWrapper.value, { top: headerHeight, bottom: 9, right: 9 })
if (rows && row === rows.length - 2) {
// if last row make 'Add New Row' visible
gridWrapper.value.scrollTo({
top: gridWrapper.value.scrollHeight,
left:
cols && col === cols.length - 2 // if corner cell
? gridWrapper.value.scrollWidth
: tdScroll.left,
behavior: 'smooth',
})
return
}
scrollToCell,
(e: KeyboardEvent) => {
// ignore navigating if picker(Date, Time, DateTime, Year)
// or single/multi select options is open
const activePickerOrDropdownEl = document.querySelector(
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
)
if (activePickerOrDropdownEl) {
e.preventDefault()
return true
}
if (cols && col === cols.length - 2) {
// if last column make 'Add New Column' visible
gridWrapper.value.scrollTo({
top: tdScroll.top,
left: gridWrapper.value.scrollWidth,
behavior: 'smooth',
})
return
// if expanded form is active skip keyboard event handling
if (document.querySelector('.nc-drawer-expanded-form.active')) {
return true
}
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (e.key === ' ') {
if (selected.row !== null && !editEnabled) {
e.preventDefault()
const row = data.value[selected.row]
expandForm(row)
return true
}
} else if (e.key === 'Escape') {
if (editEnabled) {
editEnabled = false
return true
}
} else if (e.key === 'Enter') {
if (editEnabled) {
editEnabled = false
return true
}
}
if (cmdOrCtrl) {
switch (e.key) {
case 'ArrowUp':
selected.row = 0
selected.col = selected.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowDown':
selected.row = data.value.length - 1
selected.col = selected.col ?? 0
scrollToCell?.()
editEnabled = false
return true
case 'ArrowRight':
selected.row = selected.row ?? 0
selected.col = fields.value?.length - 1
scrollToCell?.()
editEnabled = false
return true
case 'ArrowLeft':
selected.row = selected.row ?? 0
selected.col = 0
scrollToCell?.()
editEnabled = false
return true
}
}
},
)
function scrollToCell(row?: number | null, col?: number | null) {
row = row ?? selected.row
col = col ?? selected.col
if (row !== undefined && col !== undefined && row !== null && col !== null) {
// get active cell
const rows = tbodyEl.value?.querySelectorAll('tr')
const cols = rows?.[row].querySelectorAll('td')
const td = cols?.[col === 0 ? 0 : col + 1]
if (!td || !gridWrapper.value) return
// scroll into the active cell
const { height: headerHeight } = tableHead.value!.getBoundingClientRect()
const tdScroll = getContainerScrollForElement(td, gridWrapper.value, { top: headerHeight, bottom: 9, right: 9 })
if (rows && row === rows.length - 2) {
// if last row make 'Add New Row' visible
gridWrapper.value.scrollTo({
top: gridWrapper.value.scrollHeight,
left:
cols && col === cols.length - 2 // if corner cell
? gridWrapper.value.scrollWidth
: tdScroll.left,
behavior: 'smooth',
})
return
}
if (cols && col === cols.length - 2) {
// if last column make 'Add New Column' visible
gridWrapper.value.scrollTo({
top: tdScroll.top,
left: tdScroll.left,
left: gridWrapper.value.scrollWidth,
behavior: 'smooth',
})
return
}
},
)
// scroll into the active cell
gridWrapper.value.scrollTo({
top: tdScroll.top,
left: tdScroll.left,
behavior: 'smooth',
})
}
}
onMounted(loadGridViewColumns)
@ -234,7 +301,7 @@ const showLoading = ref(true)
const skipRowRemovalOnCancel = ref(false)
const expandForm = (row: Row, state?: Record<string, any>, fromToolbar = false) => {
function expandForm(row: Row, state?: Record<string, any>, fromToolbar = false) {
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
if (rowId) {
@ -325,7 +392,7 @@ useEventListener(document, 'keyup', async (e: KeyboardEvent) => {
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(smartTable, () => {
onClickOutside(smartTable, (e) => {
clearRangeRows()
if (selected.col === null) return
@ -333,12 +400,30 @@ onClickOutside(smartTable, () => {
if (editEnabled && (isVirtualCol(activeCol) || activeCol.uidt === UITypes.JSON)) return
// ignore unselecting if clicked inside or on the picker(Date, Time, DateTime, Year)
// or single/multi select options
const activePickerOrDropdownEl = document.querySelector(
'.nc-picker-datetime.active,.nc-dropdown-single-select-cell.active,.nc-dropdown-multi-select-cell.active,.nc-picker-date.active,.nc-picker-year.active,.nc-picker-time.active',
)
if (
e.target &&
activePickerOrDropdownEl &&
(activePickerOrDropdownEl === e.target || activePickerOrDropdownEl?.contains(e.target as Element))
)
return
// if expanded form is active skip resetting the active cell
if (document.querySelector('.nc-drawer-expanded-form.active')) {
return
}
selected.row = null
selected.col = null
})
const onNavigate = (dir: NavigateDir) => {
if (selected.row === null || selected.col === null) return
editEnabled = false
switch (dir) {
case NavigateDir.NEXT:
if (selected.row < data.value.length - 1) {
@ -351,8 +436,6 @@ const onNavigate = (dir: NavigateDir) => {
case NavigateDir.PREV:
if (selected.row > 0) {
selected.row--
} else {
editEnabled = false
}
break
}
@ -383,8 +466,8 @@ const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?
currentRow.rowMeta.changed = false
for (const field of (args.metaValue || meta.value)?.columns ?? []) {
if (isVirtualCol(field)) continue
if (field.title! in currentRow.row && currentRow.row[field.title!] !== currentRow.oldRow[field.title!]) {
await updateOrSaveRow(currentRow, field.title!, {}, args)
if (currentRow.row[field.title!] !== currentRow.oldRow[field.title!]) {
await updateOrSaveRow(currentRow, field.title!, args)
}
}
}
@ -437,22 +520,31 @@ provide(ReloadRowDataHookInj, reloadViewDataHook)
// trigger initial data load in grid
// reloadViewDataHook.trigger()
const switchingTab = ref(false)
watch(
view,
async (next, old) => {
if (next && next.id !== old?.id) {
// whenever tab changes or view changes save any unsaved data
if (old?.id) {
const oldMeta = await getMeta(old.fk_model_id!)
if (oldMeta) {
await saveOrUpdateRecords({
viewMetaValue: old,
metaValue: oldMeta as TableType,
data: data.value,
})
try {
if (next && next.id !== old?.id) {
switchingTab.value = true
// whenever tab changes or view changes save any unsaved data
if (old?.id) {
const oldMeta = await getMeta(old.fk_model_id!)
if (oldMeta) {
await saveOrUpdateRecords({
viewMetaValue: old,
metaValue: oldMeta as TableType,
data: data.value,
})
}
}
await loadData()
}
await loadData()
} catch (e) {
console.log(e)
} finally {
switchingTab.value = false
}
},
{ immediate: true },
@ -460,8 +552,8 @@ watch(
</script>
<template>
<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" data-nc="grid-load-spinner">
<div class="relative flex flex-col h-full min-h-0 w-full" data-testid="nc-grid-wrapper">
<general-overlay :model-value="isLoading" inline transition class="!bg-opacity-15" data-testid="grid-load-spinner">
<div class="flex items-center justify-center h-full w-full !bg-white !bg-opacity-85 z-1000">
<a-spin size="large" />
</div>
@ -480,8 +572,8 @@ watch(
>
<thead ref="tableHead">
<tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]">
<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" data-nc="nc-check-all">
<th data-testid="grid-id-column">
<div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center" data-testid="nc-check-all">
<template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div>
<div
@ -546,8 +638,8 @@ watch(
<tbody ref="tbodyEl" @selectstart.prevent>
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr class="nc-grid-row" :data-nc="`grid-row-${rowIndex}`">
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-nc="`cell-Id-${rowIndex}`">
<tr class="nc-grid-row" :data-testid="`grid-row-${rowIndex}`">
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`">
<div class="items-center flex gap-1 min-w-[55px]">
<div
v-if="!readOnly || !isLocked"
@ -568,10 +660,14 @@ watch(
<div
v-if="!readOnly || hasRole('commenter', true) || hasRole('viewer', true)"
class="nc-expand"
:data-nc="`nc-expand-${rowIndex}`"
:data-testid="`nc-expand-${rowIndex}`"
:class="{ 'nc-comment': row.rowMeta?.commentCount }"
>
<a-spin v-if="row.rowMeta.saving" class="!flex items-center" :data-nc="`row-save-spinner-${rowIndex}`" />
<a-spin
v-if="row.rowMeta.saving"
class="!flex items-center"
:data-testid="`row-save-spinner-${rowIndex}`"
/>
<template v-else>
<span
v-if="row.rowMeta?.commentCount"
@ -605,7 +701,7 @@ watch(
(hasEditPermission && selectedRange(rowIndex, colIndex)),
'nc-required-cell': isColumnRequiredAndNull(columnObj, row.row),
}"
:data-nc="`cell-${columnObj.title}-${rowIndex}`"
:data-testid="`cell-${columnObj.title}-${rowIndex}`"
:data-key="rowIndex + columnObj.id"
:data-col="columnObj.id"
:data-title="columnObj.title"
@ -615,7 +711,7 @@ watch(
@mouseover="selectBlock(rowIndex, colIndex)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
>
<div class="w-full h-full">
<div v-if="!switchingTab" class="w-full h-full">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]"
@ -758,8 +854,7 @@ watch(
text-overflow: ellipsis;
}
td.active::after,
td.active::before {
td.active::after {
content: '';
position: absolute;
z-index: 3;
@ -772,12 +867,14 @@ watch(
// todo: replace with css variable
td.active::after {
@apply border-2 border-solid border-primary;
@apply border-2 border-solid text-primary border-current bg-primary bg-opacity-5;
}
td.active::before {
@apply bg-primary bg-opacity-5;
}
//td.active::before {
// content: '';
// z-index:4;
// @apply absolute !w-[10px] !h-[10px] !right-[-5px] !bottom-[-5px] bg-primary;
//}
}
:deep {

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

@ -309,7 +309,7 @@ watch(view, async (nextView) => {
</script>
<template>
<div class="flex h-full bg-white px-2" data-nc="nc-kanban-wrapper">
<div class="flex h-full bg-white px-2" data-testid="nc-kanban-wrapper">
<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">
<!-- Draggable Stack -->

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

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

34
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -1,6 +1,22 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { ActiveCellInj, CellValueInj, ColumnInj, IsFormInj, RowInj, inject, provide, ref, toRef, useVirtualCell } from '#imports'
import {
ActiveCellInj,
CellValueInj,
ColumnInj,
IsFormInj,
RowInj,
inject,
isBt,
isCount,
isFormula,
isHm,
isLookup,
isMm,
isRollup,
provide,
toRef,
} from '#imports'
import type { Row } from '~/lib'
import { NavigateDir } from '~/lib'
@ -24,8 +40,6 @@ provide(CellValueInj, toRef(props, 'modelValue'))
const isForm = inject(IsFormInj, ref(false))
const { isLookup, isBt, isRollup, isMm, isHm, isFormula, isCount } = useVirtualCell(column)
function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir)
@ -39,12 +53,12 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
>
<LazyVirtualCellHasMany v-if="isHm" />
<LazyVirtualCellManyToMany v-else-if="isMm" />
<LazyVirtualCellBelongsTo v-else-if="isBt" />
<LazyVirtualCellRollup v-else-if="isRollup" />
<LazyVirtualCellFormula v-else-if="isFormula" />
<LazyVirtualCellCount v-else-if="isCount" />
<LazyVirtualCellLookup v-else-if="isLookup" />
<LazyVirtualCellHasMany v-if="isHm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" />
</div>
</template>

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

@ -1,4 +1,5 @@
<script lang="ts" setup>
import { useEventListener } from '@vueuse/core'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
IsFormInj,
@ -116,6 +117,12 @@ onMounted(() => {
formState.value.column_name = formState.value?.title
}
})
useEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
emit('cancel')
}
})
</script>
<template>
@ -124,7 +131,7 @@ onMounted(() => {
:class="{ '!w-[600px]': formState.uidt === UITypes.Formula }"
@click.stop
>
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-nc="add-or-edit-column">
<a-form v-model="formState" no-style name="column-create-or-edit" layout="vertical" data-testid="add-or-edit-column">
<div class="flex flex-col gap-2">
<a-form-item :label="$t('labels.columnName')" v-bind="validateInfos.title">
<a-input

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

@ -137,7 +137,7 @@ watch(inputs, () => {
v-if="!isKanban"
small
class="nc-child-draggable-icon handle"
:data-nc="`select-option-column-handle-icon-${element.title}`"
:data-testid="`select-option-column-handle-icon-${element.title}`"
/>
<a-dropdown
v-model:visible="colorMenus[index]"
@ -162,25 +162,24 @@ watch(inputs, () => {
ref="inputs"
v-model:value="element.title"
class="caption"
:data-nc="`select-column-option-input-${index}`"
:data-testid="`select-column-option-input-${index}`"
@keydown.enter.prevent="element.title?.trim() && addNewOption()"
@change="optionChanged(element.id)"
/>
<MdiClose
class="ml-2 hover:!text-black"
:style="{ color: 'red' }"
:data-nc="`select-column-option-remove-${index}`"
class="ml-2 hover:!text-black-500 text-gray-500 cursor-pointer"
:data-testid="`select-column-option-remove-${index}`"
@click="removeOption(index)"
/>
</div>
</template>
<template #footer>
<div v-if="validateInfos?.['colOptions.options']?.help?.[0]?.[0]" class="text-error text-[10px] my-2">
{{ validateInfos['colOptions.options'].help[0][0] }}
</div>
</template>
</Draggable>
</div>
<div v-if="validateInfos?.['colOptions.options']?.help?.[0]?.[0]" class="text-error text-[10px] mb-1 mt-2">
{{ validateInfos['colOptions.options'].help[0][0] }}
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewOption()">
<div class="flex items-center">
<MdiPlus />

50
packages/nc-gui/components/smartsheet/expanded-form/Header.vue

@ -25,6 +25,8 @@ const { isUIAllowed } = useUIPermission()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const saveRowAndStay = ref(0)
const save = async () => {
if (isNew.value) {
const data = await _save(state.value)
@ -34,6 +36,9 @@ const save = async () => {
await _save()
reloadTrigger?.trigger()
}
if (!saveRowAndStay.value) {
emit('cancel')
}
}
// todo: accept as a prop / inject
@ -45,9 +50,11 @@ const { copy } = useClipboard()
const copyRecordUrl = () => {
copy(
`${dashboardUrl?.value}#/${route.params.projectType}/${route.params.projectId}/${route.params.type}/${meta.value?.title}${
props.view ? `/${props.view.title}` : ''
}?rowId=${primaryKey.value}`,
encodeURI(
`${dashboardUrl?.value}#/${route.params.projectType}/${route.params.projectId}/${route.params.type}/${meta.value?.title}${
props.view ? `/${props.view.title}` : ''
}?rowId=${primaryKey.value}`,
),
)
message.success('Copied to clipboard')
}
@ -99,14 +106,39 @@ const copyRecordUrl = () => {
</a-tooltip>
<a-button class="!text mx-1 nc-expand-form-close-btn" @click="emit('cancel')">
<!-- Cancel -->
{{ $t('general.cancel') }}
<div class="flex items-center">
<MdiCloseCircleOutline class="mr-1" />
<!-- Close -->
{{ $t('general.close') }}
</div>
</a-button>
<a-button :disabled="!isUIAllowed('tableRowUpdate')" type="primary" class="mx-1" @click="save">
<!-- Save Row -->
{{ $t('activity.saveRow') }}
</a-button>
<a-dropdown-button class="nc-expand-form-save-btn" type="primary" :disabled="!isUIAllowed('tableRowUpdate')" @click="save">
<template #overlay>
<a-menu class="nc-expand-form-save-dropdown-menu">
<a-menu-item key="0" class="!py-2 flex gap-2" @click="saveRowAndStay = 0">
<div class="flex items-center">
<MdiContentSave class="mr-1" />
{{ $t('activity.saveAndExit') }}
</div>
</a-menu-item>
<a-menu-item key="1" class="!py-2 flex gap-2 items-center" @click="saveRowAndStay = 1">
<div class="flex items-center">
<MdiContentSaveEdit class="mr-1" />
{{ $t('activity.saveAndStay') }}
</div>
</a-menu-item>
</a-menu>
</template>
<div v-if="saveRowAndStay === 0" class="flex items-center">
<MdiContentSave class="mr-1" />
{{ $t('activity.saveAndExit') }}
</div>
<div v-if="saveRowAndStay === 1" class="flex items-center">
<MdiContentSaveEdit class="mr-1" />
{{ $t('activity.saveAndStay') }}
</div>
</a-dropdown-button>
</div>
</template>

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

@ -121,6 +121,12 @@ if (isKanban.value) {
}
}
}
const cellWrapperEl = (wrapperEl: HTMLElement) => {
nextTick(() => {
;(wrapperEl?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
})
}
</script>
<script lang="ts">
@ -137,6 +143,7 @@ export default {
:body-style="{ 'padding': 0, 'display': 'flex', 'flex-direction': 'column' }"
:closable="false"
class="nc-drawer-expanded-form"
:class="{ active: isExpanded }"
>
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" />
@ -145,18 +152,18 @@ export default {
<div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container">
<div class="w-[500px] mx-auto">
<div
v-for="col of fields"
v-for="(col, i) of fields"
v-show="!isVirtualCol(col) || !isNew || col.uidt === UITypes.LinkToAnotherRecord"
:key="col.title"
class="mt-2 py-2"
:class="`nc-expand-col-${col.title}`"
:data-nc="`nc-expand-col-${col.title}`"
:data-testid="`nc-expand-col-${col.title}`"
>
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" />
<LazySmartsheetHeaderCell v-else :column="col" />
<div class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2">
<div :ref="i ? null : cellWrapperEl" class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2">
<LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :row="row" :column="col" />
<LazySmartsheetCell

132
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -0,0 +1,132 @@
import type { ColumnType } from 'nocodb-sdk'
import type { PropType } from '@vue/runtime-core'
import {
ColumnInj,
computed,
defineComponent,
h,
inject,
isAttachment,
isBoolean,
isCurrency,
isDate,
isDateTime,
isDecimal,
isDuration,
isEmail,
isFloat,
isInt,
isJSON,
isPercent,
isPhoneNumber,
isPrimary,
isRating,
isSet,
isSingleSelect,
isSpecificDBType,
isString,
isTextArea,
isTime,
isURL,
isYear,
toRef,
useProject,
} from '#imports'
import FilePhoneIcon from '~icons/mdi/file-phone'
import KeyIcon from '~icons/mdi/key-variant'
import JSONIcon from '~icons/mdi/code-json'
import ClockIcon from '~icons/mdi/clock-time-five'
import WebIcon from '~icons/mdi/web'
import TextAreaIcon from '~icons/mdi/card-text-outline'
import StringIcon from '~icons/mdi/alpha-a-box-outline'
import BooleanIcon from '~icons/mdi/check-box-outline'
import CalendarIcon from '~icons/mdi/calendar'
import SingleSelectIcon from '~icons/mdi/arrow-down-drop-circle'
import MultiSelectIcon from '~icons/mdi/format-list-bulleted-square'
import DatetimeIcon from '~icons/mdi/calendar-clock'
import RatingIcon from '~icons/mdi/star'
import GenericIcon from '~icons/mdi/square-rounded'
import NumericIcon from '~icons/mdi/numeric'
import AttachmentIcon from '~icons/mdi/image-multiple-outline'
import EmailIcon from '~icons/mdi/email'
import CurrencyIcon from '~icons/mdi/currency-usd-circle-outline'
import PercentIcon from '~icons/mdi/percent-outline'
import DecimalIcon from '~icons/mdi/decimal'
import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import DurationIcon from '~icons/mdi/timer-outline'
const renderIcon = (column: ColumnType, abstractType: any) => {
if (isPrimary(column)) {
return KeyIcon
} else if (isJSON(column)) {
return JSONIcon
} else if (isDate(column, abstractType)) {
return CalendarIcon
} else if (isDateTime(column, abstractType)) {
return DatetimeIcon
} else if (isSet(column)) {
return MultiSelectIcon
} else if (isSingleSelect(column)) {
return SingleSelectIcon
} else if (isBoolean(column, abstractType)) {
return BooleanIcon
} else if (isTextArea(column)) {
return TextAreaIcon
} else if (isEmail(column)) {
return EmailIcon
} else if (isYear(column, abstractType)) {
return CalendarIcon
} else if (isTime(column, abstractType)) {
return ClockIcon
} else if (isRating(column)) {
return RatingIcon
} else if (isAttachment(column)) {
return AttachmentIcon
} else if (isDecimal(column)) {
return DecimalIcon
} else if (isPhoneNumber(column)) {
return FilePhoneIcon
} else if (isURL(column)) {
return WebIcon
} else if (isCurrency(column)) {
return CurrencyIcon
} else if (isDuration(column)) {
return DurationIcon
} else if (isPercent(column)) {
return PercentIcon
} else if (isInt(column, abstractType) || isFloat(column, abstractType)) {
return NumericIcon
} else if (isString(column, abstractType)) {
return StringIcon
} else if (isSpecificDBType(column)) {
return SpecificDBTypeIcon
} else {
return GenericIcon
}
}
export default defineComponent({
name: 'CellIcon',
props: {
columnMeta: {
type: Object as PropType<ColumnType>,
required: false,
},
},
setup(props) {
const columnMeta = toRef(props, 'columnMeta')
const column = inject(ColumnInj, columnMeta)
const { sqlUi } = useProject()
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))
return () => {
if (!column.value) return null
return h(renderIcon(column.value, abstractType.value), { class: 'text-grey mx-1 !text-xs' })
}
},
})

89
packages/nc-gui/components/smartsheet/header/CellIcon.vue

@ -1,89 +0,0 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { ColumnInj, computed, inject, toRef, useColumn } from '#imports'
import FilePhoneIcon from '~icons/mdi/file-phone'
import KeyIcon from '~icons/mdi/key-variant'
import JSONIcon from '~icons/mdi/code-json'
import ClockIcon from '~icons/mdi/clock-time-five'
import WebIcon from '~icons/mdi/web'
import TextAreaIcon from '~icons/mdi/card-text-outline'
import StringIcon from '~icons/mdi/alpha-a-box-outline'
import BooleanIcon from '~icons/mdi/check-box-outline'
import CalendarIcon from '~icons/mdi/calendar'
import SingleSelectIcon from '~icons/mdi/arrow-down-drop-circle'
import MultiSelectIcon from '~icons/mdi/format-list-bulleted-square'
import DatetimeIcon from '~icons/mdi/calendar-clock'
import RatingIcon from '~icons/mdi/star'
import GenericIcon from '~icons/mdi/square-rounded'
import NumericIcon from '~icons/mdi/numeric'
import AttachmentIcon from '~icons/mdi/image-multiple-outline'
import EmailIcon from '~icons/mdi/email'
import CurrencyIcon from '~icons/mdi/currency-usd-circle-outline'
import PercentIcon from '~icons/mdi/percent-outline'
import DecimalIcon from '~icons/mdi/decimal'
import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import DurationIcon from '~icons/mdi/timer-outline'
const props = defineProps<{ columnMeta?: ColumnType }>()
const columnMeta = toRef(props, 'columnMeta')
const column = inject(ColumnInj, columnMeta)
const additionalColMeta = useColumn(column as Ref<ColumnType>)
const icon = computed(() => {
if (column?.value?.pk) {
return KeyIcon
} else if (additionalColMeta.isJSON.value) {
return JSONIcon
} else if (additionalColMeta.isDate.value) {
return CalendarIcon
} else if (additionalColMeta.isDateTime.value) {
return DatetimeIcon
} else if (additionalColMeta.isSet.value) {
return MultiSelectIcon
} else if (additionalColMeta.isSingleSelect.value) {
return SingleSelectIcon
} else if (additionalColMeta.isBoolean.value) {
return BooleanIcon
} else if (additionalColMeta.isTextArea.value) {
return TextAreaIcon
} else if (additionalColMeta.isEmail.value) {
return EmailIcon
} else if (additionalColMeta.isYear.value) {
return CalendarIcon
} else if (additionalColMeta.isTime.value) {
return ClockIcon
} else if (additionalColMeta.isRating.value) {
return RatingIcon
} else if (additionalColMeta.isAttachment.value) {
return AttachmentIcon
} else if (additionalColMeta.isDecimal.value) {
return DecimalIcon
} else if (additionalColMeta.isPhoneNumber.value) {
return FilePhoneIcon
} else if (additionalColMeta.isURL.value) {
return WebIcon
} else if (additionalColMeta.isCurrency.value) {
return CurrencyIcon
} else if (additionalColMeta.isDuration.value) {
return DurationIcon
} else if (additionalColMeta.isPercent.value) {
return PercentIcon
} else if (additionalColMeta.isInt.value || additionalColMeta.isFloat.value) {
return NumericIcon
} else if (additionalColMeta.isString.value) {
return StringIcon
} else if (additionalColMeta.isSpecificDBType.value) {
return SpecificDBTypeIcon
} else {
return GenericIcon
}
})
</script>
<template>
<component :is="icon" class="text-grey mx-1 !text-xs" />
</template>

27
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -7,6 +7,12 @@ import {
MetaInj,
computed,
inject,
isBt,
isFormula,
isHm,
isLookup,
isMm,
isRollup,
isVirtualColRequired,
provide,
ref,
@ -14,7 +20,6 @@ import {
useI18n,
useMetas,
useUIPermission,
useVirtualCell,
} from '#imports'
const props = defineProps<{ column: ColumnType; hideMenu?: boolean; required?: boolean | number }>()
@ -37,14 +42,12 @@ const meta = inject(MetaInj, ref())
const isForm = inject(IsFormInj, ref(false))
const { isLookup, isBt, isRollup, isMm, isHm, isFormula } = useVirtualCell(column)
const colOptions = $computed(() => column.value?.colOptions)
const tableTile = $computed(() => meta?.value?.title)
const relationColumnOptions = $computed<LinkToAnotherRecordType | null>(() => {
if (isMm.value || isHm.value || isBt.value) {
if (isMm(column.value) || isHm(column.value) || isBt(column.value)) {
return column.value?.colOptions as LinkToAnotherRecordType
} else if ((column?.value?.colOptions as LookupType | RollupType)?.fk_relation_column_id) {
return meta?.value?.columns?.find(
@ -62,10 +65,10 @@ const relatedTableTitle = $computed(() => relatedTableMeta?.title)
const childColumn = $computed(() => {
if (relatedTableMeta?.columns) {
if (isRollup.value) {
if (isRollup(column.value)) {
return relatedTableMeta?.columns.find((c: ColumnType) => c.id === (colOptions as RollupType).fk_rollup_column_id)
}
if (isLookup.value) {
if (isLookup(column.value)) {
return relatedTableMeta?.columns.find((c: ColumnType) => c.id === (colOptions as LookupType).fk_lookup_column_id)
}
}
@ -76,22 +79,22 @@ const tooltipMsg = computed(() => {
if (!column.value) {
return ''
}
if (isHm.value) {
if (isHm(column.value)) {
return `'${tableTile}' ${t('labels.hasMany')} '${relatedTableTitle}'`
} else if (isMm.value) {
} else if (isMm(column.value)) {
return `'${tableTile}' & '${relatedTableTitle}' ${t('labels.manyToMany')}`
} else if (isBt.value) {
} else if (isBt(column.value)) {
return `'${column?.value?.title}' ${t('labels.belongsTo')} '${relatedTableTitle}'`
} else if (isLookup.value) {
} else if (isLookup(column.value)) {
return `'${childColumn.title}' from '${relatedTableTitle}' (${childColumn.uidt})`
} else if (isFormula.value) {
} else if (isFormula(column.value)) {
const formula = substituteColumnIdWithAliasInFormula(
(column.value?.colOptions as FormulaType)?.formula,
meta?.value?.columns as ColumnType[],
(column.value?.colOptions as any)?.formula_raw,
)
return `Formula - ${formula}`
} else if (isRollup.value) {
} else if (isRollup(column.value)) {
return `'${childColumn.title}' of '${relatedTableTitle}' (${childColumn.uidt})`
}
return ''

75
packages/nc-gui/components/smartsheet/header/VirtualCellIcon.vue → packages/nc-gui/components/smartsheet/header/VirtualCellIcon.ts

@ -1,8 +1,8 @@
<script setup lang="ts">
import type { PropType } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, LookupType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { ColumnInj, inject, ref, toRef } from '#imports'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import { ColumnInj, MetaInj, defineComponent, h, inject, isBt, isHm, isLookup, isMm, isRollup, ref, toRef } from '#imports'
import GenericIcon from '~icons/mdi/square-rounded'
import HMIcon from '~icons/mdi/table-arrow-right'
import BTIcon from '~icons/mdi/table-arrow-left'
@ -13,30 +13,10 @@ import CountIcon from '~icons/mdi/counter'
import SpecificDBTypeIcon from '~icons/mdi/database-settings'
import TableColumnPlusBefore from '~icons/mdi/table-column-plus-before'
const props = defineProps<{ columnMeta?: ColumnType }>()
const columnMeta = toRef(props, 'columnMeta')
const column = inject(ColumnInj, ref(columnMeta)) as Ref<ColumnType & { colOptions: LookupType }>
let relationColumn: ColumnType & { colOptions: LookupType }
if (column) {
const { isLookup, isBt, isRollup, isMm, isHm } = useVirtualCell(column as Ref<ColumnType>)
if (isLookup || isBt || isRollup || isMm || isHm) {
const meta = inject(MetaInj, ref())
relationColumn = meta.value?.columns?.find((c) => c.id === column.value?.colOptions?.fk_relation_column_id) as ColumnType & {
colOptions: LinkToAnotherRecordType
}
}
}
const icon = computed(() => {
switch (column?.value?.uidt) {
const renderIcon = (column: ColumnType, relationColumn?: ColumnType) => {
switch (column.uidt) {
case UITypes.LinkToAnotherRecord:
switch ((column?.value?.colOptions as LinkToAnotherRecordType)?.type) {
switch ((column.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY:
return { icon: MMIcon, color: 'text-accent' }
case RelationTypes.HAS_MANY:
@ -72,10 +52,43 @@ const icon = computed(() => {
case UITypes.Count:
return { icon: CountIcon, color: 'text-grey' }
}
return { icon: GenericIcon, color: 'text-grey' }
})
</script>
}
export default defineComponent({
name: 'VirtualCellIcon',
props: {
columnMeta: {
type: Object as PropType<ColumnType>,
required: false,
},
},
setup(props) {
const columnMeta = toRef(props, 'columnMeta')
const column = inject(ColumnInj, columnMeta) as Ref<ColumnType & { colOptions: LookupType }>
let relationColumn: ColumnType & { colOptions: LookupType }
return () => {
if (!column.value) return null
<template>
<component :is="icon.icon" class="mx-1 !text-xs" :class="icon.color" />
</template>
if (column && column.value) {
if (isMm(column.value) || isHm(column.value) || isBt(column.value) || isLookup(column.value) || isRollup(column.value)) {
const meta = inject(MetaInj, ref())
relationColumn = meta.value?.columns?.find(
(c) => c.id === column.value?.colOptions?.fk_relation_column_id,
) as ColumnType & {
colOptions: LinkToAnotherRecordType
}
}
}
const { icon: Icon, color } = renderIcon(column.value, relationColumn)
return h(Icon, { class: `${color} mx-1 !text-xs` })
}
},
})

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

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

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

@ -21,8 +21,18 @@ const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const { activeTab } = useTabs()
const { views, loadViews, isLoading } = useViews(meta)
const { lastOpenedViewMap } = useProject()
const setLastOpenedViewId = (viewId?: string) => {
if (viewId && activeTab.value?.id) {
lastOpenedViewMap.value[activeTab.value?.id] = viewId
}
}
const { isUIAllowed } = useUIPermission()
const router = useRouter()
@ -43,10 +53,14 @@ const sidebar = ref()
watch(
[views, () => route.params.viewTitle],
([nextViews, viewTitle]) => {
const lastOpenedViewId = activeTab.value?.id && lastOpenedViewMap.value[activeTab.value?.id]
const lastOpenedView = nextViews.find((v) => v.id === lastOpenedViewId)
if (viewTitle) {
let view = nextViews.find((v) => v.title === viewTitle)
if (view) {
activeView.value = view
setLastOpenedViewId(activeView.value?.id)
} else {
/** search with view id and if found replace with title */
view = nextViews.find((v) => v.id === viewTitle)
@ -58,6 +72,13 @@ watch(
})
}
}
} else if (lastOpenedView) {
/** if active view is not found, set it to last opened view */
router.replace({
params: {
viewTitle: lastOpenedView.title,
},
})
} else {
if (nextViews?.length && activeView.value !== nextViews[0]) {
activeView.value = nextViews[0]

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

@ -8,6 +8,7 @@ import {
inject,
ref,
useGlobal,
useMenuCloseOnEsc,
useNuxtApp,
useSmartsheetStoreOrThrow,
useViewFilters,
@ -62,10 +63,14 @@ const filterAutoSaveLoc = computed({
filterAutoSave.value = val
},
})
const open = ref(false)
useMenuCloseOnEsc(open)
</script>
<template>
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-filter-menu">
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-filter-menu">
<div :class="{ 'nc-badge nc-active-btn': filtersLength }">
<a-button v-e="['c:filter']" class="nc-filter-menu-btn nc-toolbar-btn txt-sm" :disabled="isLocked">
<div class="flex items-center gap-1">
@ -82,7 +87,7 @@ const filterAutoSaveLoc = computed({
ref="filterComp"
class="nc-table-toolbar-menu shadow-lg"
:auto-save="filterAutoSave"
data-nc="nc-filter-menu"
data-testid="nc-filter-menu"
@update:filters-length="filtersLength = $event"
>
<div v-if="!isPublic" class="flex items-end mt-2 min-h-[30px]" @click.stop>

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

@ -14,6 +14,7 @@ import {
inject,
ref,
resolveComponent,
useMenuCloseOnEsc,
useNuxtApp,
useViewColumns,
watch,
@ -119,10 +120,14 @@ const getIcon = (c: ColumnType) =>
h(isVirtualCol(c) ? resolveComponent('SmartsheetHeaderVirtualCellIcon') : resolveComponent('SmartsheetHeaderCellIcon'), {
columnMeta: c,
})
const open = ref(false)
useMenuCloseOnEsc(open)
</script>
<template>
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-fields-menu">
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-fields-menu">
<div :class="{ 'nc-badge nc-active-btn': isAnyFieldHidden }">
<a-button v-e="['c:fields']" class="nc-fields-menu-btn nc-toolbar-btn" :disabled="isLocked">
<div class="flex items-center gap-1">
@ -139,7 +144,7 @@ const getIcon = (c: ColumnType) =>
<template #overlay>
<div
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"
data-testid="nc-fields-menu"
@click.stop
>
<a-card
@ -167,7 +172,7 @@ const getIcon = (c: ColumnType) =>
v-show="filteredFieldList.includes(field)"
:key="field.id"
class="px-2 py-1 flex items-center"
:data-nc="`nc-fields-menu-${field.title}`"
:data-testid="`nc-fields-menu-${field.title}`"
@click.stop
>
<a-checkbox

16
packages/nc-gui/components/smartsheet/toolbar/KanbanStackEditOrAdd.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { IsLockedInj, IsPublicInj, useKanbanViewStoreOrThrow } from '#imports'
import { IsLockedInj, IsPublicInj, useKanbanViewStoreOrThrow, useMenuCloseOnEsc } from '#imports'
const { isUIAllowed } = useUIPermission()
@ -7,12 +7,14 @@ const { groupingFieldColumn } = useKanbanViewStoreOrThrow()
const isLocked = inject(IsLockedInj, ref(false))
const addOrEditStackDropdown = ref(false)
const IsPublic = inject(IsPublicInj, ref(false))
const open = ref(false)
useMenuCloseOnEsc(open)
const handleSubmit = async () => {
addOrEditStackDropdown.value = false
open.value = false
}
provide(IsKanbanInj, ref(true))
@ -21,7 +23,7 @@ provide(IsKanbanInj, ref(true))
<template>
<a-dropdown
v-if="!IsPublic && isUIAllowed('edit-column')"
v-model:visible="addOrEditStackDropdown"
v-model:visible="open"
:trigger="['click']"
overlay-class-name="nc-dropdown-kanban-add-edit-stack-menu"
>
@ -42,10 +44,10 @@ provide(IsKanbanInj, ref(true))
</div>
<template #overlay>
<LazySmartsheetColumnEditOrAddProvider
v-if="addOrEditStackDropdown"
v-if="open"
:column="groupingFieldColumn"
@submit="handleSubmit"
@cancel="addOrEditStackDropdown = false"
@cancel="open = false"
@click.stop
@keydown.stop
/>

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

@ -5,12 +5,12 @@ import tinycolor from 'tinycolor2'
import {
computed,
extractSdkResponseErrorMsg,
isRtlLang,
message,
projectThemeColors,
ref,
useCopy,
useDashboard,
useDebounceFn,
useI18n,
useNuxtApp,
useProject,
@ -40,13 +40,21 @@ const passwordProtected = ref(false)
const shared = ref<SharedView>({ id: '', meta: {}, password: undefined })
const transitionDuration = computed({
get: () => shared.value.meta.transitionDuration || 250,
set: (duration) => {
shared.value.meta = { ...shared.value.meta, transitionDuration: duration > 5000 ? 5000 : duration }
const withRTL = computed({
get: () => !!shared.value.meta.rtl,
set: (rtl) => {
shared.value.meta = { ...shared.value.meta, rtl }
updateSharedViewMeta()
},
})
// const transitionDuration = computed({
// get: () => shared.value.meta.transitionDuration || 50,
// set: (duration) => {
// shared.value.meta = { ...shared.value.meta, transitionDuration: duration > 5000 ? 5000 : duration }
// },
// })
const allowCSVDownload = computed({
get: () => !!shared.value.meta.allowCSVDownload,
set: (allow) => {
@ -105,7 +113,7 @@ const sharedViewUrl = computed(() => {
viewType = 'view'
}
return `${dashboardUrl?.value}#/nc/${viewType}/${shared.value.uuid}`
return encodeURI(`${dashboardUrl?.value}#/nc/${viewType}/${shared.value.uuid}`)
})
async function saveAllowCSVDownload() {
@ -123,7 +131,7 @@ async function saveTheme() {
$e(`a:view:share:${viewTheme.value ? 'enable' : 'disable'}-theme`)
}
const saveTransitionDuration = useDebounceFn(updateSharedViewMeta, 1000, { maxWait: 2000 })
// const saveTransitionDuration = useDebounceFn(updateSharedViewMeta, 1000, { maxWait: 2000 })
async function updateSharedViewMeta() {
try {
@ -184,6 +192,10 @@ watch(passwordProtected, (value) => {
saveShareLinkPassword()
}
})
const { locale } = useI18n()
const isRtl = computed(() => isRtlLang(locale.value as any))
</script>
<template>
@ -212,8 +224,7 @@ watch(passwordProtected, (value) => {
wrap-class-name="nc-modal-share-view"
>
<div
data-cy="nc-modal-share-view__link"
data-nc="nc-modal-share-view__link"
data-testid="nc-modal-share-view__link"
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>
@ -235,14 +246,13 @@ watch(passwordProtected, (value) => {
<a-checkbox
v-if="shared.type === ViewTypes.FORM"
v-model:checked="surveyMode"
data-cy="nc-modal-share-view__survey-mode"
data-nc="nc-modal-share-view__survey-mode"
data-testid="nc-modal-share-view__survey-mode"
class="!text-sm"
>
Use Survey Mode
</a-checkbox>
<Transition name="layout" mode="out-in">
<!-- <Transition name="layout" mode="out-in">
<div v-if="surveyMode" class="flex flex-col justify-center pl-6">
<a-form-item class="!my-1" :has-feedback="false" name="transitionDuration">
<template #label>
@ -250,7 +260,7 @@ watch(passwordProtected, (value) => {
</template>
<a-input
v-model:value="transitionDuration"
data-cy="nc-form-signin__email"
data-testid="nc-form-signin__email"
size="small"
class="!w-32"
type="number"
@ -258,44 +268,15 @@ watch(passwordProtected, (value) => {
/>
</a-form-item>
</div>
</Transition>
</div>
<div>
<!-- todo: i18n -->
<a-checkbox
v-if="shared.type === ViewTypes.FORM"
v-model:checked="viewTheme"
data-cy="nc-modal-share-view__with-theme"
data-nc="nc-modal-share-view__with-theme"
class="!text-sm"
>
Use Theme
</a-checkbox>
<Transition name="layout" mode="out-in">
<div v-if="viewTheme" class="flex pl-6">
<LazyGeneralColorPicker
data-cy="nc-modal-share-view__theme-picker"
class="!p-0"
:model-value="shared.meta.theme?.primaryColor"
:colors="projectThemeColors"
:row-size="9"
:advanced="false"
data-nc="nc-modal-share-view__theme-picker"
@input="onChangeTheme"
/>
</div>
</Transition>
</Transition> -->
</div>
<div>
<!-- Password Protection -->
<a-checkbox
v-model:checked="passwordProtected"
data-cy="nc-modal-share-view__with-password"
data-testid="nc-modal-share-view__with-password"
class="!text-sm !my-1"
data-nc="nc-modal-share-view__with-password"
>
{{ $t('msg.info.beforeEnablePwd') }}
</a-checkbox>
@ -304,8 +285,7 @@ watch(passwordProtected, (value) => {
<div v-if="passwordProtected" class="pl-6 flex gap-2 mt-2 mb-4">
<a-input
v-model:value="shared.password"
data-cy="nc-modal-share-view__password"
data-nc="nc-modal-share-view__password"
data-testid="nc-modal-share-view__password"
size="small"
class="!text-xs max-w-[250px]"
type="password"
@ -313,8 +293,7 @@ watch(passwordProtected, (value) => {
/>
<a-button
data-cy="nc-modal-share-view__save-password"
data-nc="nc-modal-share-view__save-password"
data-testid="nc-modal-share-view__save-password"
size="small"
class="!text-xs"
@click="saveShareLinkPassword"
@ -325,21 +304,45 @@ watch(passwordProtected, (value) => {
</Transition>
</div>
<div>
<div
v-if="
shared && (shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN || shared.type === ViewTypes.GALLERY)
"
>
<!-- Allow Download -->
<a-checkbox
v-if="
shared &&
(shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN || shared.type === ViewTypes.GALLERY)
"
v-model:checked="allowCSVDownload"
data-cy="nc-modal-share-view__with-csv-download"
data-nc="nc-modal-share-view__with-csv-download"
class="!text-sm"
>
<a-checkbox v-model:checked="allowCSVDownload" data-testid="nc-modal-share-view__with-csv-download" class="!text-sm">
{{ $t('labels.downloadAllowed') }}
</a-checkbox>
</div>
<div v-if="shared.type === ViewTypes.FORM">
<!-- todo: i18n -->
<a-checkbox v-model:checked="viewTheme" data-testid="nc-modal-share-view__with-theme" class="!text-sm">
Use Theme
</a-checkbox>
<Transition name="layout" mode="out-in">
<div v-if="viewTheme" class="flex pl-6">
<LazyGeneralColorPicker
data-testid="nc-modal-share-view__theme-picker"
class="!p-0"
:model-value="shared.meta.theme?.primaryColor"
:colors="projectThemeColors"
:row-size="9"
:advanced="false"
@input="onChangeTheme"
/>
</div>
</Transition>
</div>
<div v-if="shared.type === ViewTypes.FORM && isRtl">
<!-- use RTL orientation in form - todo: i18n -->
<a-checkbox v-model:checked="withRTL" data-testid="nc-modal-share-view__locale" class="!text-sm">
<!-- todo i18n -->
RTL Orientation
</a-checkbox>
</div>
</div>
</div>
</a-modal>

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

@ -9,6 +9,7 @@ import {
getSortDirectionOptions,
inject,
ref,
useMenuCloseOnEsc,
useViewSorts,
watch,
} from '#imports'
@ -37,10 +38,14 @@ watch(
},
{ immediate: true },
)
const open = ref(false)
useMenuCloseOnEsc(open)
</script>
<template>
<a-dropdown offset-y class="" :trigger="['click']" overlay-class-name="nc-dropdown-sort-menu">
<a-dropdown v-model:visible="open" offset-y class="" :trigger="['click']" overlay-class-name="nc-dropdown-sort-menu">
<div :class="{ 'nc-badge nc-active-btn': sorts?.length }">
<a-button v-e="['c:sort']" class="nc-sort-menu-btn nc-toolbar-btn" :disabled="isLocked">
<div class="flex items-center gap-1">
@ -55,7 +60,7 @@ watch(
<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"
data-nc="nc-sorts-menu"
data-testid="nc-sorts-menu"
>
<div v-if="sorts?.length" class="sort-grid mb-2" @click.stop>
<template v-for="(sort, i) in sorts || []" :key="i">

11
packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue

@ -12,6 +12,7 @@ import {
inject,
ref,
useKanbanViewStoreOrThrow,
useMenuCloseOnEsc,
useViewColumns,
watch,
} from '#imports'
@ -30,7 +31,9 @@ const { fields, loadViewColumns, metaColumnById } = useViewColumns(activeView, m
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
const stackedByDropdown = ref(false)
const open = ref(false)
useMenuCloseOnEsc(open)
watch(
() => activeView.value?.id,
@ -68,14 +71,14 @@ const singleSelectFieldOptions = computed<SelectProps['options']>(() => {
})
const handleChange = () => {
stackedByDropdown.value = false
open.value = false
}
</script>
<template>
<a-dropdown
v-if="!IsPublic"
v-model:visible="stackedByDropdown"
v-model:visible="open"
:trigger="['click']"
overlay-class-name="nc-dropdown-kanban-stacked-by-menu"
>
@ -97,7 +100,7 @@ const handleChange = () => {
</div>
<template #overlay>
<div
v-if="stackedByDropdown"
v-if="open"
class="p-3 min-w-[280px] bg-gray-50 shadow-lg nc-table-toolbar-menu max-h-[max(80vh,500px)] overflow-auto !border"
@click.stop
>

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

@ -8,6 +8,7 @@ import {
message,
ref,
useI18n,
useMenuCloseOnEsc,
useNuxtApp,
useProject,
useSmartsheetStoreOrThrow,
@ -79,11 +80,15 @@ async function changeLockType(type: LockType) {
}
const { isSqlView } = useSmartsheetStoreOrThrow()
const open = ref(false)
useMenuCloseOnEsc(open)
</script>
<template>
<div>
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-actions-menu">
<a-dropdown v-model:visible="open" :trigger="['click']" overlay-class-name="nc-dropdown-actions-menu">
<a-button v-e="['c:actions']" class="nc-actions-menu-btn nc-toolbar-btn">
<div class="flex gap-2 items-center">
<component
@ -103,7 +108,7 @@ const { isSqlView } = useSmartsheetStoreOrThrow()
</a-button>
<template #overlay>
<a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded" data-nc="toolbar-actions">
<a-menu class="ml-6 !text-sm !px-0 !py-2 !rounded" data-testid="toolbar-actions" @click="open = false">
<a-menu-item-group>
<a-sub-menu
v-if="isUIAllowed('view-type')"

134
packages/nc-gui/components/tabs/auth/UserManagement.vue

@ -1,4 +1,5 @@
<script setup lang="ts">
import { OrgUserRoles } from 'nocodb-sdk'
import type { RequestParams } from 'nocodb-sdk'
import {
extractSdkResponseErrorMsg,
@ -160,6 +161,10 @@ onBeforeMount(async () => {
})
watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
const isSuperAdmin = (user: { main_roles?: string }) => {
return user.main_roles?.split(',').includes(OrgUserRoles.SUPER_ADMIN)
}
</script>
<template>
@ -252,6 +257,13 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</div>
<div class="flex w-1/6 justify-center flex-wrap ml-4">
<div
v-if="isSuperAdmin(user)"
class="rounded-full px-2 py-1 nc-user-role"
:style="{ backgroundColor: projectRoleTagColors[OrgUserRoles.SUPER_ADMIN] }"
>
Super Admin
</div>
<div
v-if="user.roles"
class="rounded-full px-2 py-1 nc-user-role"
@ -261,71 +273,73 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</div>
</div>
<div class="flex w-1/6 flex-wrap justify-end">
<a-tooltip v-if="user.project_id" placement="bottom">
<template #title>
<span>{{ $t('activity.editUser') }}</span>
</template>
<a-button type="text" class="!rounded-md nc-user-edit" @click="onEdit(user)">
<template #icon>
<IcRoundEdit class="flex mx-auto h-[1rem] text-gray-500" />
<template v-if="!isSuperAdmin(user)">
<a-tooltip v-if="user.project_id" placement="bottom">
<template #title>
<span>{{ $t('activity.editUser') }}</span>
</template>
</a-button>
</a-tooltip>
<!-- Add user to project -->
<a-tooltip v-if="!user.project_id" placement="bottom">
<template #title>
<span>{{ $t('activity.addUserToProject') }}</span>
</template>
<a-button type="text" class="!rounded-md nc-user-invite" @click="inviteUser(user)">
<template #icon>
<MdiPlus class="flex mx-auto h-[1.1rem] text-gray-500" />
<a-button type="text" class="!rounded-md nc-user-edit" @click="onEdit(user)">
<template #icon>
<IcRoundEdit class="flex mx-auto h-[1rem] text-gray-500" />
</template>
</a-button>
</a-tooltip>
<!-- Add user to project -->
<a-tooltip v-if="!user.project_id" placement="bottom">
<template #title>
<span>{{ $t('activity.addUserToProject') }}</span>
</template>
</a-button>
</a-tooltip>
<!-- Remove user from the project -->
<a-tooltip v-else placement="bottom">
<template #title>
<span>{{ $t('activity.deleteUser') }}</span>
</template>
<a-button v-e="['c:user:delete']" type="text" class="!rounded-md nc-user-delete" @click="onDelete(user)">
<template #icon>
<MdiDeleteOutline class="flex mx-auto h-[1.1rem] text-gray-500" />
<a-button type="text" class="!rounded-md nc-user-invite" @click="inviteUser(user)">
<template #icon>
<MdiPlus class="flex mx-auto h-[1.1rem] text-gray-500" />
</template>
</a-button>
</a-tooltip>
<!-- Remove user from the project -->
<a-tooltip v-else placement="bottom">
<template #title>
<span>{{ $t('activity.deleteUser') }}</span>
</template>
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert />
</div>
<a-button v-e="['c:user:delete']" type="text" class="!rounded-md nc-user-delete" @click="onDelete(user)">
<template #icon>
<MdiDeleteOutline class="flex mx-auto h-[1.1rem] text-gray-500" />
</template>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<!-- Resend invite Email -->
<div class="flex flex-row items-center py-3" @click="resendInvite(user)">
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.resendInvite') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(user)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div>
</a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-mgmt">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert />
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<!-- Resend invite Email -->
<div class="flex flex-row items-center py-3" @click="resendInvite(user)">
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.resendInvite') }}</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(user)">
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</div>
</div>

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

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

10
packages/nc-gui/components/tabs/auth/user-management/UsersModal.vue

@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Input } from 'ant-design-vue'
import {
Form,
computed,
extractSdkResponseErrorMsg,
isEmail,
message,
onMounted,
projectRoleTagColors,
@ -14,6 +14,7 @@ import {
useI18n,
useNuxtApp,
useProject,
validateEmail,
} from '#imports'
import type { User } from '~/lib'
import { ProjectRole } from '~/lib'
@ -57,7 +58,7 @@ const validators = computed(() => {
callback('Email is required')
return
}
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !isEmail(e))
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !validateEmail(e))
if (invalidEmails.length > 0) {
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `)
} else {
@ -133,6 +134,10 @@ const clickInviteMore = () => {
usersData.role = ProjectRole.Viewer
usersData.emails = undefined
}
const emailField = (inputEl: typeof Input) => {
inputEl?.$el?.focus()
}
</script>
<template>
@ -222,6 +227,7 @@ const clickInviteMore = () => {
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div>
<a-input
:ref="emailField"
v-model:value="usersData.emails"
validate-trigger="onBlur"
:placeholder="$t('labels.email')"

10
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -15,6 +15,7 @@ import {
inject,
ref,
useProvideLTARStore,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useUIPermission,
} from '#imports'
@ -70,6 +71,15 @@ const unlinkRef = async (rec: Record<string, any>) => {
await unlink(rec)
}
}
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
</script>
<template>

36
packages/nc-gui/components/virtual-cell/Formula.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { CellValueInj, ColumnInj, computed, handleTZ, inject, ref, replaceUrlsWithLink, useProject } from '#imports'
import { CellValueInj, ColumnInj, computed, handleTZ, inject, ref, refAutoReset, replaceUrlsWithLink, useProject } from '#imports'
// todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
@ -10,19 +10,25 @@ const cellValue = inject(CellValueInj)
const { isPg } = useProject()
const showEditFormulaWarning = ref(false)
const showEditFormulaWarningMessage = () => {
showEditFormulaWarning.value = true
setTimeout(() => {
showEditFormulaWarning.value = false
}, 3000)
}
const result = computed(() => (isPg.value ? handleTZ(cellValue?.value) : cellValue?.value))
const urls = computed(() => replaceUrlsWithLink(result.value))
const timeout = 3000 // in ms
const showEditFormulaWarning = refAutoReset(false, timeout)
const showClearFormulaWarning = refAutoReset(false, timeout)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
showEditFormulaWarning.value = true
break
case 'Delete':
showClearFormulaWarning.value = true
break
}
})
</script>
<template>
@ -35,15 +41,19 @@ const urls = computed(() => replaceUrlsWithLink(result.value))
<span>ERR!</span>
</a-tooltip>
<div class="p-2" @dblclick="showEditFormulaWarningMessage">
<div class="p-2" @dblclick="showEditFormulaWarning = true">
<div v-if="urls" v-html="urls" />
<div v-else>{{ result }}</div>
<div v-if="showEditFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100]">
<div v-if="showEditFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Formula fields should be configured in the field menu dropdown.
</div>
<div v-if="showClearFormulaWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to clear text.
</div>
</div>
</div>
</template>

10
packages/nc-gui/components/virtual-cell/HasMany.vue

@ -13,6 +13,7 @@ import {
inject,
ref,
useProvideLTARStore,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useUIPermission,
} from '#imports'
@ -81,6 +82,15 @@ const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
</script>
<template>

147
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -1,7 +1,6 @@
<script lang="ts" setup>
import type { ColumnType, LinkToAnotherRecordType, LookupType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
CellUrlDisableOverlayInj,
CellValueInj,
@ -10,81 +9,133 @@ import {
ReadonlyInj,
computed,
inject,
isAttachment,
provide,
useColumn,
ref,
refAutoReset,
useMetas,
watch,
} from '#imports'
const { metas, getMeta } = useMetas()
provide(ReadonlyInj, ref(true))
const column = inject(ColumnInj)! as Ref<ColumnType & { colOptions: LookupType }>
const column = inject(ColumnInj, ref())
const meta = inject(MetaInj, ref())
const value = inject(CellValueInj)
const cellValue = inject(CellValueInj, ref())
const arrValue = computed(() => (Array.isArray(value?.value) ? value?.value : [value?.value]) ?? [])
const arrValue = computed(() => {
if (!cellValue.value) return []
const relationColumn = meta.value?.columns?.find((c) => c.id === column.value.colOptions?.fk_relation_column_id) as ColumnType & {
colOptions: LinkToAnotherRecordType
}
if (Array.isArray(cellValue.value)) return cellValue.value
await getMeta(relationColumn.colOptions.fk_related_model_id!)
return [cellValue.value]
})
const lookupTableMeta = computed(() => metas.value[relationColumn.colOptions.fk_related_model_id!])
const relationColumn = computed(
() =>
meta.value?.columns?.find((c) => c.id === (column.value?.colOptions as LookupType)?.fk_relation_column_id) as
| (ColumnType & {
colOptions: LinkToAnotherRecordType | undefined
})
| undefined,
)
watch(
relationColumn,
async (relationCol) => {
if (relationCol && relationCol.colOptions) await getMeta(relationCol.colOptions.fk_related_model_id!)
},
{ immediate: true },
)
const lookupTableMeta = computed<Record<string, any> | undefined>(() => {
if (relationColumn.value && relationColumn.value?.colOptions)
return metas.value[relationColumn.value.colOptions.fk_related_model_id!]
const lookupColumn = computed<any>(
return undefined
})
const lookupColumn = computed(
() =>
lookupTableMeta.value.columns?.find(
(c: Record<string, any>) => c.id === column.value.colOptions?.fk_lookup_column_id,
) as ColumnType,
lookupTableMeta.value?.columns?.find((c: any) => c.id === (column.value?.colOptions as LookupType)?.fk_lookup_column_id) as
| ColumnType
| undefined,
)
provide(MetaInj, lookupTableMeta)
provide(CellUrlDisableOverlayInj, ref(true))
const lookupColumnMetaProps = useColumn(lookupColumn)
const timeout = 3000 // in ms
const showEditWarning = refAutoReset(false, timeout)
const showClearWarning = refAutoReset(false, timeout)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
showEditWarning.value = true
break
case 'Delete':
showClearWarning.value = true
break
}
})
</script>
<template>
<div class="h-full flex gap-1 overflow-x-auto p-1">
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)">
<template
v-if="lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO"
>
<LazySmartsheetVirtualCell
<div class="h-full">
<div class="h-full flex gap-1 overflow-x-auto p-1">
<template v-if="lookupColumn">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(lookupColumn)">
<template
v-if="lookupColumn.uidt === UITypes.LinkToAnotherRecord && lookupColumn.colOptions.type === RelationTypes.BELONGS_TO"
>
<LazySmartsheetVirtualCell
v-for="(v, i) of arrValue"
:key="i"
:edit-enabled="false"
:model-value="v"
:column="lookupColumn"
/>
</template>
<LazySmartsheetVirtualCell v-else :edit-enabled="false" :model-value="arrValue" :column="lookupColumn" />
</div>
<!-- Render normal cell -->
<template v-else>
<!-- For attachment cell avoid adding chip style -->
<div
v-for="(v, i) of arrValue"
:key="i"
:edit-enabled="false"
:model-value="v"
:column="lookupColumn"
/>
class="min-w-max"
:class="{
'bg-gray-100 px-1 rounded-full flex-1': !isAttachment(lookupColumn),
' border-gray-200 rounded border-1': ![UITypes.Attachment, UITypes.MultiSelect, UITypes.SingleSelect].includes(
lookupColumn.uidt,
),
}"
>
<LazySmartsheetCell :model-value="v" :column="lookupColumn" :edit-enabled="false" :virtual="true" />
</div>
</template>
<LazySmartsheetVirtualCell v-else :edit-enabled="false" :model-value="arrValue" :column="lookupColumn" />
</div>
<!-- Render normal cell -->
<template v-else>
<!-- For attachment cell avoid adding chip style -->
<div
v-for="(v, i) of arrValue"
:key="i"
class="min-w-max"
:class="{
'bg-gray-100 px-1 rounded-full flex-1': !lookupColumnMetaProps.isAttachment,
' border-gray-200 rounded border-1': ![UITypes.Attachment, UITypes.MultiSelect, UITypes.SingleSelect].includes(
lookupColumn.uidt,
),
}"
>
<LazySmartsheetCell :model-value="v" :column="lookupColumn" :edit-enabled="false" :virtual="true" />
</div>
</template>
</template>
</div>
<div>
<div v-if="showEditWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to edit content.
</div>
<div v-if="showClearWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to clear content.
</div>
</div>
</div>
</template>

11
packages/nc-gui/components/virtual-cell/ManyToMany.vue

@ -2,6 +2,7 @@
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
ActiveCellInj,
CellValueInj,
ColumnInj,
IsFormInj,
@ -14,6 +15,7 @@ import {
inject,
ref,
useProvideLTARStore,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useUIPermission,
} from '#imports'
@ -82,6 +84,15 @@ const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
listItemsDlg.value = true
e.stopPropagation()
break
}
})
</script>
<template>

37
packages/nc-gui/components/virtual-cell/Rollup.vue

@ -1,11 +1,40 @@
<script setup lang="ts">
import { CellValueInj, inject } from '#imports'
import { CellValueInj, inject, refAutoReset } from '#imports'
const value = inject(CellValueInj)
const timeout = 3000 // in ms
const showEditWarning = refAutoReset(false, timeout)
const showClearWarning = refAutoReset(false, timeout)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
showEditWarning.value = true
break
case 'Delete':
showClearWarning.value = true
break
}
})
</script>
<template>
<span class="text-center pl-3">
{{ value }}
</span>
<div>
<span class="text-center pl-3">
{{ value }}
</span>
<div>
<div v-if="showEditWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to edit content.
</div>
<div v-if="showClearWarning" class="text-left text-wrap mt-2 text-[#e65100] text-xs">
<!-- TODO: i18n -->
Warning: Computed field - unable to clear content.
</div>
</div>
</div>
</template>

13
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -102,14 +102,19 @@ watch(
<div class="max-h-[max(calc(100vh_-_300px)_,500px)] flex flex-col py-6">
<div class="flex mb-4 items-center gap-2 px-12">
<div class="flex-1" />
<MdiReload v-if="!isForm" class="cursor-pointer text-gray-500" data-cy="nc-child-list-reload" @click="loadChildrenList" />
<MdiReload
v-if="!isForm"
class="cursor-pointer text-gray-500"
data-testid="nc-child-list-reload"
@click="loadChildrenList"
/>
<a-button
v-if="!readonly"
type="primary"
ghost
class="!text-xs"
data-cy="nc-child-list-button-link-to"
data-testid="nc-child-list-button-link-to"
size="small"
@click="emit('attachRecord')"
>
@ -143,13 +148,13 @@ watch(
<div v-if="!readonly" class="flex gap-2">
<MdiLinkVariantRemove
class="text-xs text-grey hover:(!text-red-500) cursor-pointer"
data-cy="nc-child-list-icon-unlink"
data-testid="nc-child-list-icon-unlink"
@click.stop="unlinkRow(row)"
/>
<MdiDeleteOutline
v-if="!readonly && !isPublic"
class="text-xs text-grey hover:(!text-red-500) cursor-pointer"
data-cy="nc-child-list-icon-delete"
data-testid="nc-child-list-icon-delete"
@click.stop="deleteRelatedRow(row, unlinkIfNewRow)"
/>
</div>

57
packages/nc-gui/components/virtual-cell/components/ListItems.vue

@ -1,18 +1,20 @@
<script lang="ts" setup>
import type { Card } from 'ant-design-vue'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import {
ColumnInj,
Empty,
IsPublicInj,
computed,
inject,
ref,
useLTARStoreOrThrow,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useVModel,
watch,
} from '#imports'
import { IsPublicInj } from '~/context'
const props = defineProps<{ modelValue: boolean }>()
@ -38,6 +40,8 @@ const { addLTARRef, isNew } = useSmartsheetRowStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const selectedRowIndex = ref(0)
const linkRow = async (row: Record<string, any>) => {
if (isNew.value) {
addLTARRef(row, column?.value as ColumnType)
@ -54,6 +58,7 @@ watch(vModel, (nextVal, prevVal) => {
childrenExcludedListPagination.query = ''
childrenExcludedListPagination.page = 1
loadChildrenExcludedList()
selectedRowIndex.value = 0
}
})
@ -98,6 +103,49 @@ const newRowState = computed(() => {
watch(expandedFormDlg, (nexVal) => {
if (!nexVal && !isNew.value) vModel.value = false
})
useSelectedCellKeyupListener(vModel, (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowLeft':
e.stopPropagation()
e.preventDefault()
if (childrenExcludedListPagination.page > 1) childrenExcludedListPagination.page--
break
case 'ArrowRight':
e.stopPropagation()
e.preventDefault()
if (
childrenExcludedList.value?.pageInfo &&
childrenExcludedListPagination.page <
(childrenExcludedList.value.pageInfo.totalRows || 1) / childrenExcludedListPagination.size
)
childrenExcludedListPagination.page++
break
case 'ArrowUp':
selectedRowIndex.value = Math.max(0, selectedRowIndex.value - 1)
e.stopPropagation()
e.preventDefault()
break
case 'ArrowDown':
selectedRowIndex.value = Math.min(childrenExcludedList.value?.list?.length - 1, selectedRowIndex.value + 1)
e.stopPropagation()
e.preventDefault()
break
case 'Enter':
{
const selectedRow = childrenExcludedList.value?.list?.[selectedRowIndex.value]
if (selectedRow) {
linkRow(selectedRow)
e.stopPropagation()
e.preventDefault()
}
}
break
}
})
const activeRow = (vNode?: InstanceType<typeof Card>) => {
vNode?.$el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
</script>
<template>
@ -115,6 +163,7 @@ watch(expandedFormDlg, (nexVal) => {
:placeholder="$t('placeholder.filterQuery')"
class="max-w-[200px]"
size="small"
@keydown.capture.stop
/>
<div class="flex-1" />
@ -132,7 +181,9 @@ watch(expandedFormDlg, (nexVal) => {
<a-card
v-for="(refRow, i) in childrenExcludedList?.list ?? []"
:key="i"
:ref="selectedRowIndex === i ? activeRow : null"
class="!my-4 cursor-pointer hover:(!bg-gray-200/50 shadow-md) group"
:class="{ 'nc-selected-row': selectedRowIndex === i }"
@click="linkRow(refRow)"
>
{{ refRow[relatedTablePrimaryValueProp] }}
@ -175,4 +226,8 @@ watch(expandedFormDlg, (nexVal) => {
:deep(.ant-pagination-item a) {
line-height: 21px !important;
}
:deep(.nc-selected-row) {
@apply !ring;
}
</style>

10
packages/nc-gui/composables/useApi/index.ts

@ -3,14 +3,16 @@ import { Api } from 'nocodb-sdk'
import type { Ref } from 'vue'
import type { CreateApiOptions, UseApiProps, UseApiReturn } from './types'
import { addAxiosInterceptors } from './interceptors'
import { BASE_URL, createEventHook, extractSdkResponseErrorMsg, ref, unref, useCounter, useGlobal, useNuxtApp } from '#imports'
import { BASE_FALLBACK_URL, createEventHook, extractSdkResponseErrorMsg, ref, unref, useCounter, useNuxtApp } from '#imports'
export function createApiInstance<SecurityDataType = any>({ baseURL = BASE_URL }: CreateApiOptions = {}): Api<SecurityDataType> {
const { appInfo } = $(useGlobal())
export function createApiInstance<SecurityDataType = any>({
baseURL = BASE_FALLBACK_URL,
}: CreateApiOptions = {}): Api<SecurityDataType> {
const config = useRuntimeConfig()
return addAxiosInterceptors(
new Api<SecurityDataType>({
baseURL: baseURL ?? appInfo.ncSiteUrl,
baseURL: config.public.ncBackendUrl || baseURL,
}),
)
}

96
packages/nc-gui/composables/useColumn.ts

@ -1,96 +0,0 @@
import type { ColumnType } from 'nocodb-sdk'
import { SqlUiFactory, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { ComputedRef, Ref } from 'vue'
import { computed, useProject } from '#imports'
export function useColumn(column: Ref<ColumnType | undefined>) {
const { project } = useProject()
const uiDatatype: ComputedRef<UITypes> = computed(() => column.value?.uidt as UITypes)
const abstractType = computed(() => {
// kludge: CY test hack; column.value is being received NULL during attach cell delete operation
return (column.value && isVirtualCol(column.value)) || !column.value
? null
: SqlUiFactory.create(
project.value?.bases?.[0]?.type ? { client: project.value.bases[0].type } : { client: 'mysql2' },
).getAbstractType(column.value)
})
const dataTypeLow = computed(() => column.value?.dt?.toLowerCase())
const isBoolean = computed(() => abstractType.value === 'boolean')
const isString = computed(() => uiDatatype.value === UITypes.SingleLineText || abstractType.value === 'string')
const isTextArea = computed(() => uiDatatype.value === UITypes.LongText)
const isInt = computed(() => abstractType.value === 'integer')
const isFloat = computed(() => abstractType.value === 'float' || abstractType.value === UITypes.Number)
const isDate = computed(() => abstractType.value === 'date' || uiDatatype.value === UITypes.Date)
const isYear = computed(() => abstractType.value === 'year' || uiDatatype.value === UITypes.Year)
const isTime = computed(() => abstractType.value === 'time' || uiDatatype.value === UITypes.Time)
const isDateTime = computed(() => abstractType.value === 'datetime' || uiDatatype.value === UITypes.DateTime)
const isJSON = computed(() => uiDatatype.value === UITypes.JSON)
const isEnum = computed(() => uiDatatype.value === UITypes.SingleSelect)
const isSingleSelect = computed(() => uiDatatype.value === UITypes.SingleSelect)
const isSet = computed(() => uiDatatype.value === UITypes.MultiSelect)
const isMultiSelect = computed(() => uiDatatype.value === UITypes.MultiSelect)
const isURL = computed(() => uiDatatype.value === UITypes.URL)
const isEmail = computed(() => uiDatatype.value === UITypes.Email)
const isAttachment = computed(() => uiDatatype.value === UITypes.Attachment)
const isRating = computed(() => uiDatatype.value === UITypes.Rating)
const isCurrency = computed(() => uiDatatype.value === UITypes.Currency)
const isPhoneNumber = computed(() => uiDatatype.value === UITypes.PhoneNumber)
const isDecimal = computed(() => uiDatatype.value === UITypes.Decimal)
const isDuration = computed(() => uiDatatype.value === UITypes.Duration)
const isPercent = computed(() => uiDatatype.value === UITypes.Percent)
const isSpecificDBType = computed(() => uiDatatype.value === UITypes.SpecificDBType)
const isAutoSaved = computed(() =>
[
UITypes.SingleLineText,
UITypes.LongText,
UITypes.PhoneNumber,
UITypes.Email,
UITypes.URL,
UITypes.Number,
UITypes.Decimal,
UITypes.Percent,
UITypes.Count,
UITypes.AutoNumber,
UITypes.SpecificDBType,
UITypes.Geometry,
UITypes.Duration,
].includes(uiDatatype.value),
)
const isManualSaved = computed(() => [UITypes.Currency].includes(uiDatatype.value))
const isPrimary = computed(() => column.value?.pv)
return {
abstractType,
dataTypeLow,
isPrimary,
isBoolean,
isString,
isTextArea,
isInt,
isFloat,
isDate,
isYear,
isTime,
isDateTime,
isJSON,
isEnum,
isSet,
isURL,
isEmail,
isAttachment,
isRating,
isCurrency,
isDecimal,
isDuration,
isAutoSaved,
isManualSaved,
isSingleSelect,
isMultiSelect,
isPercent,
isPhoneNumber,
isSpecificDBType,
}
}

30
packages/nc-gui/composables/useColumnCreateStore.ts

@ -97,7 +97,19 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const onUidtOrIdTypeChange = () => {
const colProp = sqlUi.value.getDataTypeForUiType(formState.value as { uidt: UITypes }, idType ?? undefined)
formState.value = {
...formState.value,
...(!isEdit.value && {
// only take title, column_name and uidt when creating a column
// to avoid the extra props from being taken (e.g. SingleLineText -> LTAR -> SingleLineText)
// to mess up the column creation
title: formState.value.title,
column_name: formState.value.column_name,
uidt: formState.value.uidt,
}),
...(isEdit.value && {
// take the existing formState.value when editing a column
// LTAR is not available in this case
...formState.value,
}),
meta: {},
rqd: false,
pk: false,
@ -183,9 +195,15 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
try {
if (!(await validate())) return
} catch (e) {
console.log(e)
console.trace()
message.error(t('msg.error.formValidationFailed'))
const errorMsgs = e.errorFields
?.map((e: any) => e.errors?.join(', '))
.filter(Boolean)
.join(', ')
if (errorMsgs) {
message.error(errorMsgs)
} else {
message.error(t('msg.error.formValidationFailed'))
}
return
}
@ -193,6 +211,10 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
formState.value.table_name = meta.value?.table_name
// formState.value.title = formState.value.column_name
if (column.value) {
// reset column validation if column is not to be validated
if (!columnToValidate.includes(formState.value.uidt)) {
formState.value.validate = ''
}
await $api.dbTableColumn.update(column.value?.id as string, formState.value)
// Column updated
message.success(t('msg.success.columnUpdated'))

3
packages/nc-gui/composables/useExpandedFormStore.ts

@ -46,8 +46,6 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
const activeView = inject(ActiveViewInj, ref())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
const { sharedView } = useSharedView()
// getters
@ -197,6 +195,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
if (activeView.value?.type === ViewTypes.KANBAN) {
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
addOrEditStackRow(row.value, isNewRow)
}

14
packages/nc-gui/composables/useGlobal/state.ts

@ -1,7 +1,17 @@
import { useStorage } from '@vueuse/core'
import type { JwtPayload } from 'jwt-decode'
import type { AppInfo, State, StoredState } from './types'
import { BASE_URL, computed, ref, toRefs, useCounter, useJwt, useNuxtApp, usePreferredLanguages, useTimestamp } from '#imports'
import {
BASE_FALLBACK_URL,
computed,
ref,
toRefs,
useCounter,
useJwt,
useNuxtApp,
usePreferredLanguages,
useTimestamp,
} from '#imports'
import type { Language, User } from '~/lib'
export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
@ -75,7 +85,7 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
})
const appInfo = ref<AppInfo>({
ncSiteUrl: BASE_URL,
ncSiteUrl: BASE_FALLBACK_URL,
authType: 'jwt',
connectToExternalDB: false,
defaultLimit: 0,

28
packages/nc-gui/composables/useMenuCloseOnEsc/index.ts

@ -0,0 +1,28 @@
import { isClient } from '@vueuse/core'
import type { Ref } from 'vue'
export function useMenuCloseOnEsc(open: Ref<boolean>) {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
open.value = false
}
}
if (isClient) {
watch(open, (nextVal, _, cleanup) => {
// bind listener when `open` is truthy
if (nextVal) {
document.addEventListener('keydown', handler, true)
// if `open` is falsy then remove the event handler
} else {
document.removeEventListener('keydown', handler, true)
}
// cleanup is called whenever the watcher is re-evaluated or stopped
cleanup(() => {
document.removeEventListener('keydown', handler, true)
})
})
}
}

32
packages/nc-gui/composables/useMultiSelect/index.ts

@ -1,6 +1,6 @@
import type { MaybeRef } from '@vueuse/core'
import { UITypes } from 'nocodb-sdk'
import { message, reactive, unref, useCopy, useEventListener, useI18n } from '#imports'
import { message, reactive, ref, unref, useCopy, useEventListener, useI18n } from '#imports'
interface SelectedBlock {
row: number | null
@ -13,16 +13,19 @@ interface SelectedBlock {
export function useMultiSelect(
fields: MaybeRef<any[]>,
data: MaybeRef<any[]>,
editEnabled: MaybeRef<boolean>,
_editEnabled: MaybeRef<boolean>,
isPkAvail: MaybeRef<boolean | undefined>,
clearCell: Function,
makeEditable: Function,
scrollToActiveCell?: (row?: number | null, col?: number | null) => void,
keyEventHandler?: Function,
) {
const { t } = useI18n()
const { copy } = useCopy()
const editEnabled = ref(_editEnabled)
const selected = reactive<SelectedBlock>({ row: null, col: null })
// save the first and the last column where the mouse is down while the value isSelectedRow is true
@ -38,6 +41,8 @@ export function useMultiSelect(
function selectCell(row: number, col: number) {
clearRangeRows()
if (selected.row === row && selected.col === col) return
editEnabled.value = false
selected.row = row
selected.col = col
}
@ -126,6 +131,11 @@ export function useMultiSelect(
})
const onKeyDown = async (e: KeyboardEvent) => {
// invoke the keyEventHandler if provided and return if it returns true
if (await keyEventHandler?.(e)) {
return true
}
if (
!isNaN(selectedRows.startRow) &&
!isNaN(selectedRows.startCol) &&
@ -148,16 +158,20 @@ export function useMultiSelect(
if (e.shiftKey) {
if (selected.col > 0) {
selected.col--
editEnabled.value = false
} else if (selected.row > 0) {
selected.row--
selected.col = unref(columnLength) - 1
editEnabled.value = false
}
} else {
if (selected.col < unref(columnLength) - 1) {
selected.col++
editEnabled.value = false
} else if (selected.row < unref(data).length - 1) {
selected.row++
selected.col = 0
editEnabled.value = false
}
}
scrollToActiveCell?.()
@ -170,11 +184,9 @@ export function useMultiSelect(
break
/** on delete key press clear cell */
case 'Delete':
if (!unref(editEnabled)) {
e.preventDefault()
clearRangeRows()
await clearCell(selected as { row: number; col: number })
}
e.preventDefault()
clearRangeRows()
await clearCell(selected as { row: number; col: number })
break
/** on arrow key press navigate through cells */
case 'ArrowRight':
@ -183,6 +195,7 @@ export function useMultiSelect(
if (selected.col < unref(columnLength) - 1) {
selected.col++
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowLeft':
@ -192,6 +205,7 @@ export function useMultiSelect(
if (selected.col > 0) {
selected.col--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowUp':
@ -201,6 +215,7 @@ export function useMultiSelect(
if (selected.row > 0) {
selected.row--
scrollToActiveCell?.()
editEnabled.value = false
}
break
case 'ArrowDown':
@ -210,6 +225,7 @@ export function useMultiSelect(
if (selected.row < unref(data).length - 1) {
selected.row++
scrollToActiveCell?.()
editEnabled.value = false
}
break
default:
@ -252,7 +268,7 @@ export function useMultiSelect(
}
if (unref(editEnabled) || e.ctrlKey || e.altKey || e.metaKey) {
return
return true
}
/** on letter key press make cell editable and empty */

8
packages/nc-gui/composables/useProject.ts

@ -2,6 +2,7 @@ import type { OracleUi, ProjectType, TableType } from 'nocodb-sdk'
import { SqlUiFactory } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import {
ClientType,
computed,
createEventHook,
ref,
@ -39,6 +40,8 @@ const [setup, use] = useInjectionState(() => {
const projectMetaInfo = ref<ProjectMetaInfo | undefined>()
const lastOpenedViewMap = ref<Record<string, string>>({})
const projectId = computed(() => route.params.projectId as string)
// todo: refactor path param name and variable name
@ -52,13 +55,13 @@ const [setup, use] = useInjectionState(() => {
}
})
const projectBaseType = $computed(() => project.value?.bases?.[0]?.type || '')
const projectBaseType = $computed(() => project.value?.bases?.[0]?.type || ClientType.MYSQL)
const sqlUi = computed(
() => SqlUiFactory.create({ client: projectBaseType }) as Exclude<ReturnType<typeof SqlUiFactory['create']>, typeof OracleUi>,
)
const isMysql = computed(() => ['mysql', 'mysql2'].includes(projectBaseType))
const isMysql = computed(() => ['mysql', ClientType.MYSQL].includes(projectBaseType))
const isMssql = computed(() => projectBaseType === 'mssql')
const isPg = computed(() => projectBaseType === 'pg')
const isSharedBase = computed(() => projectType === 'base')
@ -165,6 +168,7 @@ const [setup, use] = useInjectionState(() => {
projectLoadedHook: projectLoadedHook.on,
reset,
isLoading,
lastOpenedViewMap,
}
}, 'useProject')

21
packages/nc-gui/composables/useSelectedCellKeyupListener/index.ts

@ -0,0 +1,21 @@
import { isClient } from '@vueuse/core'
import type { Ref } from 'vue'
export function useSelectedCellKeyupListener(selected: Ref<boolean>, handler: (e: KeyboardEvent) => void) {
if (isClient) {
watch(selected, (nextVal, _, cleanup) => {
// bind listener when `selected` is truthy
if (nextVal) {
document.addEventListener('keydown', handler, true)
// if `selected` is falsy then remove the event handler
} else {
document.removeEventListener('keydown', handler, true)
}
// cleanup is called whenever the watcher is re-evaluated or stopped
cleanup(() => {
document.removeEventListener('keydown', handler, true)
})
})
}
}

25
packages/nc-gui/composables/useSmartsheetRowStore.ts

@ -8,6 +8,9 @@ import {
deepCompare,
extractPkFromRow,
extractSdkResponseErrorMsg,
isBt,
isHm,
isMm,
message,
ref,
unref,
@ -16,7 +19,6 @@ import {
useMetas,
useNuxtApp,
useProject,
useVirtualCell,
} from '#imports'
import type { Row } from '~/lib'
@ -36,12 +38,11 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
const state = ref<Record<string, Record<string, any> | Record<string, any>[] | null>>({})
// getters
const isNew = computed(() => unref(row).rowMeta?.new ?? false)
const isNew = computed(() => unref(row).rowMeta.new ?? false)
// actions
const addLTARRef = async (value: Record<string, any>, column: ColumnType) => {
const { isHm, isMm, isBt } = $(useVirtualCell(ref(column)))
if (isHm || isMm) {
if (isHm(column) || isMm(column)) {
if (!state.value[column.title!]) state.value[column.title!] = []
if (state.value[column.title!]!.find((ln: Record<string, any>) => deepCompare(ln, value))) {
@ -50,17 +51,16 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
}
state.value[column.title!]!.push(value)
} else if (isBt) {
} else if (isBt(column)) {
state.value[column.title!] = value
}
}
// actions
const removeLTARRef = async (value: Record<string, any>, column: ColumnType) => {
const { isHm, isMm, isBt } = $(useVirtualCell(ref(column)))
if (isHm || isMm) {
if (isHm(column) || isMm(column)) {
state.value[column.title!]?.splice(state.value[column.title!]?.indexOf(value), 1)
} else if (isBt) {
} else if (isBt(column)) {
state.value[column.title!] = null
}
}
@ -92,13 +92,14 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
const id = extractPkFromRow(row, metaValue?.columns as ColumnType[])
for (const column of metaValue?.columns ?? []) {
if (column.uidt !== UITypes.LinkToAnotherRecord) continue
const colOptions = column?.colOptions as LinkToAnotherRecordType
const { isHm, isMm, isBt } = $(useVirtualCell(ref(column)))
const colOptions = column.colOptions as LinkToAnotherRecordType
const relatedTableMeta = metas.value?.[colOptions?.fk_related_model_id as string]
if (isHm || isMm) {
if (isHm(column) || isMm(column)) {
const relatedRows = (state.value?.[column.title!] ?? []) as Record<string, any>[]
for (const relatedRow of relatedRows) {
await linkRecord(
id,
@ -108,7 +109,7 @@ const [useProvideSmartsheetRowStore, useSmartsheetRowStore] = useInjectionState(
{ metaValue },
)
}
} else if (isBt && state?.value?.[column.title!]) {
} else if (isBt(column) && state.value?.[column.title!]) {
await linkRecord(
id,
extractPkFromRow(state.value?.[column.title!] as Record<string, any>, relatedTableMeta.columns as ColumnType[]),

1
packages/nc-gui/composables/useTable.ts

@ -79,6 +79,7 @@ export function useTable(onTableCreate?: (tableMeta: TableType) => void) {
okText: t('general.yes'),
okType: 'danger',
cancelText: t('general.no'),
width: 450,
async onOk() {
try {
const meta = (await getMeta(table.id as string, true)) as TableType

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

Loading…
Cancel
Save