Browse Source

Merge pull request #5394 from nocodb/develop

pull/5395/head 0.106.0-beta.0
github-actions[bot] 2 years ago committed by GitHub
parent
commit
bb622199d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 18
      .github/CONTRIBUTING.md
  2. 41
      .github/workflows/ci-cd.yml
  3. 6
      .github/workflows/publish-api-docs.yml
  4. 9
      .github/workflows/release-timely-executables.yml
  5. 3
      .gitignore
  6. 90
      README.md
  7. 4
      charts/nocodb/templates/pvc.yaml
  8. 130
      packages/nc-cli/package-lock.json
  9. 2
      packages/nc-cli/package.json
  10. 15
      packages/nc-gui/app.vue
  11. 13
      packages/nc-gui/assets/css/global.css
  12. 4
      packages/nc-gui/assets/style.scss
  13. 42
      packages/nc-gui/assets/style/fonts.css
  14. BIN
      packages/nc-gui/assets/style/material.woff2
  15. 38
      packages/nc-gui/components.d.ts
  16. 9
      packages/nc-gui/components/account/License.vue
  17. 6
      packages/nc-gui/components/account/ResetPassword.vue
  18. 4
      packages/nc-gui/components/account/SignupSettings.vue
  19. 24
      packages/nc-gui/components/account/Token.vue
  20. 34
      packages/nc-gui/components/account/UserList.vue
  21. 40
      packages/nc-gui/components/account/UsersModal.vue
  22. 6
      packages/nc-gui/components/api-client/Headers.vue
  23. 6
      packages/nc-gui/components/api-client/Params.vue
  24. 15
      packages/nc-gui/components/cell/Checkbox.vue
  25. 4
      packages/nc-gui/components/cell/ClampedText.vue
  26. 5
      packages/nc-gui/components/cell/Currency.vue
  27. 3
      packages/nc-gui/components/cell/DatePicker.vue
  28. 5
      packages/nc-gui/components/cell/DateTimePicker.vue
  29. 7
      packages/nc-gui/components/cell/Decimal.vue
  30. 3
      packages/nc-gui/components/cell/Duration.vue
  31. 43
      packages/nc-gui/components/cell/Email.vue
  32. 9
      packages/nc-gui/components/cell/Float.vue
  33. 186
      packages/nc-gui/components/cell/GeoData.vue
  34. 9
      packages/nc-gui/components/cell/Integer.vue
  35. 11
      packages/nc-gui/components/cell/MultiSelect.vue
  36. 4
      packages/nc-gui/components/cell/Rating.vue
  37. 18
      packages/nc-gui/components/cell/SingleSelect.vue
  38. 7
      packages/nc-gui/components/cell/Text.vue
  39. 11
      packages/nc-gui/components/cell/TextArea.vue
  40. 1
      packages/nc-gui/components/cell/TimePicker.vue
  41. 13
      packages/nc-gui/components/cell/Url.vue
  42. 8
      packages/nc-gui/components/cell/attachment/Carousel.vue
  43. 2
      packages/nc-gui/components/cell/attachment/Image.vue
  44. 9
      packages/nc-gui/components/cell/attachment/Modal.vue
  45. 8
      packages/nc-gui/components/cell/attachment/index.vue
  46. 6
      packages/nc-gui/components/cell/attachment/utils.ts
  47. 199
      packages/nc-gui/components/dashboard/TreeView.vue
  48. 8
      packages/nc-gui/components/dashboard/settings/AppStore.vue
  49. 10
      packages/nc-gui/components/dashboard/settings/AuditTab.vue
  50. 44
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  51. 10
      packages/nc-gui/components/dashboard/settings/Metadata.vue
  52. 6
      packages/nc-gui/components/dashboard/settings/Misc.vue
  53. 20
      packages/nc-gui/components/dashboard/settings/Modal.vue
  54. 10
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  55. 41
      packages/nc-gui/components/dashboard/settings/app-store/AppInstall.vue
  56. 14
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  57. 16
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  58. 12
      packages/nc-gui/components/dlg/AirtableImport.vue
  59. 19
      packages/nc-gui/components/dlg/KeyboardShortcuts.vue
  60. 21
      packages/nc-gui/components/dlg/QuickImport.vue
  61. 29
      packages/nc-gui/components/dlg/TableCreate.vue
  62. 34
      packages/nc-gui/components/dlg/TableRename.vue
  63. 68
      packages/nc-gui/components/dlg/ViewCreate.vue
  64. 5
      packages/nc-gui/components/erd/HistogramPanel.vue
  65. 8
      packages/nc-gui/components/erd/View.vue
  66. 4
      packages/nc-gui/components/general/AddBaseButton.vue
  67. 47
      packages/nc-gui/components/general/EmojiIcons.vue
  68. 6
      packages/nc-gui/components/general/HelpAndSupport.vue
  69. 11
      packages/nc-gui/components/general/Icon.vue
  70. 6
      packages/nc-gui/components/general/JoinCloud.vue
  71. 20
      packages/nc-gui/components/general/MiniSidebar.vue
  72. 19
      packages/nc-gui/components/general/PreviewAs.vue
  73. 4
      packages/nc-gui/components/general/ShareBaseButton.vue
  74. 57
      packages/nc-gui/components/general/ShortcutLabel.vue
  75. 26
      packages/nc-gui/components/general/Social.vue
  76. 20
      packages/nc-gui/components/general/SocialCard.vue
  77. 7
      packages/nc-gui/components/general/TableIcon.vue
  78. 42
      packages/nc-gui/components/shared-view/Map.vue
  79. 5
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  80. 34
      packages/nc-gui/components/smartsheet/Cell.vue
  81. 47
      packages/nc-gui/components/smartsheet/Form.vue
  82. 3
      packages/nc-gui/components/smartsheet/Gallery.vue
  83. 79
      packages/nc-gui/components/smartsheet/Grid.vue
  84. 64
      packages/nc-gui/components/smartsheet/Kanban.vue
  85. 285
      packages/nc-gui/components/smartsheet/Map.vue
  86. 4
      packages/nc-gui/components/smartsheet/Pagination.vue
  87. 3
      packages/nc-gui/components/smartsheet/Row.vue
  88. 94
      packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue
  89. 26
      packages/nc-gui/components/smartsheet/Toolbar.vue
  90. 4
      packages/nc-gui/components/smartsheet/column/BarcodeOptions.vue
  91. 20
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  92. 5
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  93. 16
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  94. 4
      packages/nc-gui/components/smartsheet/column/LookupOptions.vue
  95. 4
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  96. 10
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  97. 2
      packages/nc-gui/components/smartsheet/column/SpecificDBTypeOptions.vue
  98. 203
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  99. 107
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  100. 151
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  101. Some files were not shown because too many files have changed in this diff Show More

18
.github/CONTRIBUTING.md

@ -8,30 +8,22 @@ Thanks for spending your time to contribute! The following is a set of guideline
- [Development Setup](#development-setup)
* [Committing Changes](#committing-changes)
* [Applying License](#applying-license)
+ [Modifying existing file](#modifying-existing-file)
+ [Creating new file](#creating-new-file)
+ [Sign your existing work](#sign-your-existing-work)
+ [Sign your previous work](#sign-your-previous-work)
- [Project Structure](#project-structure)
- [Financial Contribution](#financial-contribution)
- [Credits](#credits)
## Pull Request Guidelines
- When you create a PR, you should fill in all the info defined in this [template](https://github.com/nocodb/nocodb/blob/master/.github/pull_request_template.md).
- When you create a PR, you should fill in all the info defined in this [template](https://github.com/nocodb/nocodb/blob/master/.github/PULL_REQUEST_TEMPLATE.md).
- We adopt [Gitflow Design](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow). However, we do not have release branches.
![git flow design](https://wac-cdn.atlassian.com/dam/jcr:cc0b526e-adb7-4d45-874e-9bcea9898b4a/04%20Hotfix%20branches.svg?cdnVersion=176)
- The `master` branch is just a snapshot of the latest stable release. All development should be done in dedicated branches.
**Do not submit PRs against the `master` branch.**
- The `master` branch is just a snapshot of the latest stable release. All development should be done in dedicated branches (e.g. `feat/foo`, `fix/bar`, `enhancement/baz`). All approved PRs will go to `develop` branch. **Do not submit PRs against the `master` branch.**
- Checkout a topic branch from the relevant branch, e.g. `develop`, and merge back against that branch.
- Multiple small commits are allowed on the PR - They will be squashed into one commit before merging.
- If your changes are related to a special issue, add `ref: #xxx` to link the issue where xxx is the issue id.
- If your changes are related to a special issue, add `ref: #xxx` to link the issue where `xxx` is the issue id. If your changes are meant to solve the issue, then add `closes: #xxx` instead.
- If your changes doesn't relate to any issues, we suggest you to create a new issue first and ask for assignment. Also, it'd be better to discuss the design or solutions with the team members via Discord first.
## Development Setup

41
.github/workflows/ci-cd.yml

@ -63,6 +63,47 @@ jobs:
- name: run unit tests
working-directory: ./packages/nocodb
run: npm run test:unit
unit-tests-pg:
runs-on: ubuntu-20.04
timeout-minutes: 40
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: setup pg
working-directory: ./
run: docker-compose -f ./tests/playwright/scripts/docker-compose-playwright-pg.yml up -d &
- 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:main
- name: Install dependencies
working-directory: ./packages/nocodb
run: npm install
- name: run unit tests
working-directory: ./packages/nocodb
run: npm run test:unit:pg
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

6
.github/workflows/publish-api-docs.yml

@ -4,7 +4,7 @@ on:
push:
branches: [ master ]
paths:
- "scripts/sdk/swagger.json"
- "packages/nocodb/src/schema/swagger.json"
release:
types: [ published ]
@ -22,7 +22,7 @@ jobs:
env:
API_TOKEN_GITHUB: ${{ secrets.GH_TOKEN }}
with:
source_file: 'scripts/sdk/swagger.json'
source_file: 'packages/nocodb/src/schema/swagger.json'
destination_repo: 'nocodb/noco-apis-doc'
destination_folder: 'src'
user_email: 'oof1lab@gmail.com'
@ -34,7 +34,7 @@ jobs:
env:
API_TOKEN_GITHUB: ${{ secrets.GH_TOKEN }}
with:
source_file: 'scripts/sdk/swagger.json'
source_file: 'packages/nocodb/src/schema/swagger.json'
destination_repo: 'nocodb/noco-apis-doc'
destination_folder: 'meta-src'
user_email: 'oof1lab@gmail.com'

9
.github/workflows/release-timely-executables.yml

@ -62,6 +62,10 @@ jobs:
./make.sh
sudo cp ./ldid /usr/local/bin
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Update nocodb-timely
env:
TAG: ${{ github.event.inputs.tag || inputs.tag }}
@ -75,11 +79,6 @@ jobs:
git tag $TAG
git push --tags
- uses: actions/setup-node@v3
with:
node-version: 16
- name : Install dependencies and build executables
run: |
# install npm dependendencies

3
.gitignore vendored

@ -88,3 +88,6 @@ mongod
#=========
nc_minimal_dbs/
test_noco.db
# ngrok config
httpbin

90
README.md

@ -10,14 +10,13 @@
</h1>
<p align="center">
Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart-spreadsheet.
Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart spreadsheet.
</p>
<div align="center">
[![Build Status](https://travis-ci.org/dwyl/esta.svg?branch=master)](https://travis-ci.com/github/NocoDB/NocoDB)
[![Node version](https://img.shields.io/badge/node-%3E%3D%2014.18.0-brightgreen)](http://nodejs.org/download/)
[![Node version](https://img.shields.io/badge/node-%3E%3D%2016.14.0-brightgreen)](http://nodejs.org/download/)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-green.svg)](https://conventionalcommits.org)
</div>
@ -29,7 +28,7 @@ Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart-spreadshe
<a href="https://twitter.com/nocodb"><b>Twitter</b></a>
<a href="https://www.reddit.com/r/NocoDB/"><b>Reddit</b></a>
<a href="https://docs.nocodb.com/"><b>Documentation</b></a>
</p>
</p>
![All Views](https://user-images.githubusercontent.com/35857179/194825053-3aa3373d-3e0f-4b42-b3f1-42928332054a.gif)
@ -47,12 +46,12 @@ Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart-spreadshe
</div>
<p align="center"><a href="markdown/readme/languages/README.md"><b>See other languages »</b></a></p>
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
# Join Our Team
<p align=""><a href="http://careers.nocodb.com" target="_blank"><img src="https://user-images.githubusercontent.com/61551451/169663818-45643495-e95b-48e2-be13-01d6a77dc2fd.png" width="250"/></a></p>
# Join Our Community
@ -65,14 +64,14 @@ Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart-spreadshe
<img src="https://i2.wp.com/www.feverbee.com/wp-content/uploads/2018/07/logo-discourse.png" alt="">
</a>
-->
[![Stargazers repo roster for @nocodb/nocodb](https://reporoster.com/stars/nocodb/nocodb)](https://github.com/nocodb/nocodb/stargazers)
[![Stargazers repo roster for @nocodb/nocodb](https://reporoster.com/stars/nocodb/nocodb)](https://github.com/nocodb/nocodb/stargazers)
# Quick try
## NPX
You can run below command if you need an interactive configuration.
You can run the below command if you need an interactive configuration.
```
npx create-nocodb-app
@ -91,7 +90,7 @@ npm install
npm start
```
## Docker
## Docker
```bash
# for SQLite
@ -130,32 +129,40 @@ nocodb/nocodb:latest
> If you plan to input some special characters, you may need to change the character set and collation yourself when creating the database. Please check out the examples for [MySQL Docker](https://github.com/nocodb/nocodb/issues/1340#issuecomment-1049481043).
## Binaries
##### MacOS (x64)
```bash
curl http://get.nocodb.com/macos-x64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### MacOS (arm64)
```bash
curl http://get.nocodb.com/macos-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### Linux (x64)
```bash
curl http://get.nocodb.com/linux-x64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### Linux (arm64)
```bash
curl http://get.nocodb.com/linux-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### Windows (x64)
```bash
iwr http://get.nocodb.com/win-x64.exe
.\Noco-win-x64.exe
```
##### Windows (arm64)
```bash
iwr http://get.nocodb.com/win-arm64.exe
.\Noco-win-arm64.exe
@ -182,7 +189,7 @@ docker-compose up -d
# GUI
Access Dashboard using : [http://localhost:8080/dashboard](http://localhost:8080/dashboard)
Access Dashboard using: [http://localhost:8080/dashboard](http://localhost:8080/dashboard)
# Screenshots
@ -202,22 +209,22 @@ Access Dashboard using : [http://localhost:8080/dashboard](http://localhost:8080
# Table of Contents
- [Quick try](#quick-try)
* [NPX](#npx)
* [Node Application](#node-application)
* [Docker](#docker)
* [Docker Compose](#docker-compose)
- [NPX](#npx)
- [Node Application](#node-application)
- [Docker](#docker)
- [Docker Compose](#docker-compose)
- [GUI](#gui)
- [Join Our Community](#join-our-community)
- [Screenshots](#screenshots)
- [Table of Contents](#table-of-contents)
- [Features](#features)
+ [Rich Spreadsheet Interface](#rich-spreadsheet-interface)
+ [App Store for Workflow Automations](#app-store-for-workflow-automations)
+ [Programmatic Access](#programmatic-access)
+ [Sync Schema](#sync-schema)
+ [Audit](#audit)
- [Rich Spreadsheet Interface](#rich-spreadsheet-interface)
- [App Store for Workflow Automations](#app-store-for-workflow-automations)
- [Programmatic Access](#programmatic-access)
- [Sync Schema](#sync-schema)
- [Audit](#audit)
- [Production Setup](#production-setup)
* [Environment variables](#environment-variables)
- [Environment variables](#environment-variables)
- [Development Setup](#development-setup)
- [Contributing](#contributing)
- [Why are we building this?](#why-are-we-building-this)
@ -229,47 +236,47 @@ Access Dashboard using : [http://localhost:8080/dashboard](http://localhost:8080
### Rich Spreadsheet Interface
- ⚡ &nbsp;Basic Operations: Create, Read, Update and Delete on Tables, Columns, and Rows
- ⚡ &nbsp;Basic Operations: Create, Read, Update and Delete Tables, Columns, and Rows
- ⚡ &nbsp;Fields Operations: Sort, Filter, Hide / Unhide Columns
- ⚡ &nbsp;Multiple Views Types: Grid (By default), Gallery, Form View and Kanban View
- ⚡ &nbsp;View Permissions Types: Collaborative Views, & Locked Views
- ⚡ &nbsp;View Permissions Types: Collaborative Views, & Locked Views
- ⚡ &nbsp;Share Bases / Views: either Public or Private (with Password Protected)
- ⚡ &nbsp;Variant Cell Types: ID, LinkToAnotherRecord, Lookup, Rollup, SingleLineText, Attachement, Currency, Formula and etc
- ⚡ &nbsp;Access Control with Roles : Fine-grained Access Control at different levels
- ⚡ &nbsp;Variant Cell Types: ID, LinkToAnotherRecord, Lookup, Rollup, SingleLineText, Attachment, Currency, Formula, etc
- ⚡ &nbsp;Access Control with Roles: Fine-grained Access Control at different levels
- ⚡ &nbsp;and more ...
### App Store for Workflow Automations
We provide different integrations in three main categories. See <a href="https://docs.nocodb.com/setup-and-usages/app-store" target="_blank">App Store</a> for details.
We provide different integrations in three main categories. See <a href="https://docs.nocodb.com/setup-and-usages/account-settings#app-store" target="_blank">App Store</a> for details.
- ⚡ &nbsp;Chat : Slack, Discord, Mattermost, and etc
- ⚡ &nbsp;Email : AWS SES, SMTP, MailerSend, and etc
- ⚡ &nbsp;Storage : AWS S3, Google Cloud Storage, Minio, and etc
- ⚡ &nbsp;Chat: Slack, Discord, Mattermost, and etc
- ⚡ &nbsp;Email: AWS SES, SMTP, MailerSend, and etc
- ⚡ &nbsp;Storage: AWS S3, Google Cloud Storage, Minio, and etc
### Programmatic Access
We provide the following ways to let users to invoke actions in a programmatic way. You can use a token (either JWT or Social Auth) to sign your requests for authorization to NocoDB.
We provide the following ways to let users programmatically invoke actions. You can use a token (either JWT or Social Auth) to sign your requests for authorization to NocoDB.
- ⚡ &nbsp;REST APIs
- ⚡ &nbsp;NocoDB SDK
### Sync Schema
We allow you to sync schema changes if you have made changes outside NocoDB GUI. However, it has to be noted then you will have to bring your own schema migrations for moving from environment to others. See <a href="https://docs.nocodb.com/setup-and-usages/sync-schema/" target="_blank">Sync Schema</a> for details.
We allow you to sync schema changes if you have made changes outside NocoDB GUI. However, it has to be noted then you will have to bring your own schema migrations for moving from one environment to another. See <a href="https://docs.nocodb.com/setup-and-usages/sync-schema/" target="_blank">Sync Schema</a> for details.
### Audit
### Audit
We are keeping all the user operation logs under one place. See <a href="https://docs.nocodb.com/setup-and-usages/audit" target="_blank">Audit</a> for details.
We are keeping all the user operation logs in one place. See <a href="https://docs.nocodb.com/setup-and-usages/audit" target="_blank">Audit</a> for details.
# Production Setup
# Production Setup
By default, SQLite is used for storing meta data. However, you can specify your own database. The connection params for this database can be specified in `NC_DB` environment variable. Moreover, we also provide the below environment variables for configuration.
By default, SQLite is used for storing metadata. However, you can specify your database. The connection parameters for this database can be specified in `NC_DB` environment variable. Moreover, we also provide the below environment variables for configuration.
## Environment variables
## Environment variables
Please refer to [Environment variables](https://docs.nocodb.com/getting-started/environment-variables)
Please refer to the [Environment variables](https://docs.nocodb.com/getting-started/environment-variables)
# Development Setup
# Development Setup
Please refer to [Development Setup](https://docs.nocodb.com/engineering/development-setup)
@ -278,12 +285,15 @@ Please refer to [Development Setup](https://docs.nocodb.com/engineering/developm
Please refer to [Contribution Guide](https://github.com/nocodb/nocodb/blob/master/.github/CONTRIBUTING.md).
# Why are we building this?
Most internet businesses equip themselves with either spreadsheet or a database to solve their business needs. Spreadsheets are used by a Billion+ humans collaboratively every single day. However, we are way off working at similar speeds on databases which are way more powerful tools when it comes to computing. Attempts to solve this with SaaS offerings has meant horrible access controls, vendor lockin, data lockin, abrupt price changes & most importantly a glass ceiling on what's possible in future.
Most internet businesses equip themselves with either spreadsheet or a database to solve their business needs. Spreadsheets are used by Billion+ humans collaboratively every single day. However, we are way off working at similar speeds on databases which are way more powerful tools when it comes to computing. Attempts to solve this with SaaS offerings have meant horrible access controls, vendor lock-in, data lock-in, abrupt price changes & most importantly a glass ceiling on what's possible in the future.
# Our Mission
Our mission is to provide the most powerful no-code interface for databases which is open source to every single internet business in the world. This would not only democratise access to a powerful computing tool but also bring forth a billion+ people who will have radical tinkering-and-building abilities on internet.
# License
Our mission is to provide the most powerful no-code interface for databases that is open source to every single internet business in the world. This would not only democratise access to a powerful computing tool but also bring forth a billion+ people who will have radical tinkering-and-building abilities on the internet.
# License
<p>
This project is licensed under <a href="./LICENSE">AGPLv3</a>.
</p>
@ -294,4 +304,4 @@ Thank you for your contributions! We appreciate all the contributions from the c
<a href="https://github.com/nocodb/nocodb/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nocodb/nocodb" />
</a>
</a>

4
charts/nocodb/templates/pvc.yaml

@ -5,10 +5,10 @@ metadata:
labels:
{{- include "nocodb.selectorLabels" . | nindent 8 }}
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: {{ .Values.storage.size }}
storageClassName: {{ .Values.storage.storageClassName }}
accessModes:
{{- default (toYaml .Values.storage.accessModes) "- ReadWriteMany" | nindent 4 }}
volumeMode: Filesystem

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

@ -61,7 +61,7 @@
"tslint-config-prettier": "^1.18.0",
"tslint-immutable": "^6.0.1",
"typescript": "^3.5.3",
"webpack": "^5.1.0",
"webpack": "^5.76.0",
"webpack-cli": "^4.10.0",
"webpack-node-externals": "^2.5.2"
},
@ -921,9 +921,9 @@
}
},
"node_modules/@types/eslint": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.2.2.tgz",
"integrity": "sha512-nQxgB8/Sg+QKhnV8e0WzPpxjIGT3tuJDDzybkDi8ItE/IgTlHo07U0shaIjzhcvQxlq9SDRE42lsJ23uvEgJ2A==",
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.2.tgz",
"integrity": "sha512-EMpxUyystd3uZVByZap1DACsMXvb82ypQnGn89e1Y0a+LYu3JJscUd/gqhRsVFDkaD2MIiWo0MT8EfXr3DGRKw==",
"dev": true,
"dependencies": {
"@types/estree": "*",
@ -931,9 +931,9 @@
}
},
"node_modules/@types/eslint-scope": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.2.tgz",
"integrity": "sha512-TzgYCWoPiTeRg6RQYgtuW7iODtVoKu3RVL72k3WohqhjfaOLK5Mg2T4Tg1o2bSfu0vPkoI48wdQFv5b/Xe04wQ==",
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz",
"integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==",
"dev": true,
"dependencies": {
"@types/eslint": "*",
@ -941,9 +941,9 @@
}
},
"node_modules/@types/estree": {
"version": "0.0.50",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz",
"integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==",
"version": "0.0.51",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true
},
"node_modules/@types/glob": {
@ -1220,9 +1220,9 @@
}
},
"node_modules/acorn": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz",
"integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==",
"version": "8.8.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
"integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
"dev": true,
"bin": {
"acorn": "bin/acorn"
@ -14972,9 +14972,9 @@
}
},
"node_modules/watchpack": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz",
"integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
"integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
"dev": true,
"dependencies": {
"glob-to-regexp": "^0.4.1",
@ -14998,35 +14998,35 @@
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
},
"node_modules/webpack": {
"version": "5.65.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.65.0.tgz",
"integrity": "sha512-Q5or2o6EKs7+oKmJo7LaqZaMOlDWQse9Tm5l1WAfU/ujLGN5Pb0SqGeVkN/4bpPmEqEP5RnVhiqsOtWtUVwGRw==",
"version": "5.76.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz",
"integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==",
"dev": true,
"dependencies": {
"@types/eslint-scope": "^3.7.0",
"@types/estree": "^0.0.50",
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^0.0.51",
"@webassemblyjs/ast": "1.11.1",
"@webassemblyjs/wasm-edit": "1.11.1",
"@webassemblyjs/wasm-parser": "1.11.1",
"acorn": "^8.4.1",
"acorn": "^8.7.1",
"acorn-import-assertions": "^1.7.6",
"browserslist": "^4.14.5",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.8.3",
"enhanced-resolve": "^5.10.0",
"es-module-lexer": "^0.9.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.4",
"json-parse-better-errors": "^1.0.2",
"graceful-fs": "^4.2.9",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^3.1.0",
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.1.3",
"watchpack": "^2.3.1",
"webpack-sources": "^3.2.2"
"watchpack": "^2.4.0",
"webpack-sources": "^3.2.3"
},
"bin": {
"webpack": "bin/webpack.js"
@ -15197,18 +15197,18 @@
"dev": true
},
"node_modules/webpack-sources": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.2.tgz",
"integrity": "sha512-cp5qdmHnu5T8wRg2G3vZZHoJPN14aqQ89SyQ11NpGH5zEMDCclt49rzo+MaRazk7/UeILhAI+/sEtcM+7Fr0nw==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
"dev": true,
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/webpack/node_modules/enhanced-resolve": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz",
"integrity": "sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA==",
"version": "5.12.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz",
"integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.4",
@ -16170,9 +16170,9 @@
"dev": true
},
"@types/eslint": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.2.2.tgz",
"integrity": "sha512-nQxgB8/Sg+QKhnV8e0WzPpxjIGT3tuJDDzybkDi8ItE/IgTlHo07U0shaIjzhcvQxlq9SDRE42lsJ23uvEgJ2A==",
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.2.tgz",
"integrity": "sha512-EMpxUyystd3uZVByZap1DACsMXvb82ypQnGn89e1Y0a+LYu3JJscUd/gqhRsVFDkaD2MIiWo0MT8EfXr3DGRKw==",
"dev": true,
"requires": {
"@types/estree": "*",
@ -16180,9 +16180,9 @@
}
},
"@types/eslint-scope": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.2.tgz",
"integrity": "sha512-TzgYCWoPiTeRg6RQYgtuW7iODtVoKu3RVL72k3WohqhjfaOLK5Mg2T4Tg1o2bSfu0vPkoI48wdQFv5b/Xe04wQ==",
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz",
"integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==",
"dev": true,
"requires": {
"@types/eslint": "*",
@ -16190,9 +16190,9 @@
}
},
"@types/estree": {
"version": "0.0.50",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz",
"integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==",
"version": "0.0.51",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz",
"integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==",
"dev": true
},
"@types/glob": {
@ -16447,9 +16447,9 @@
}
},
"acorn": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz",
"integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==",
"version": "8.8.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
"integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==",
"dev": true
},
"acorn-import-assertions": {
@ -26952,9 +26952,9 @@
"integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
},
"watchpack": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.3.1.tgz",
"integrity": "sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
"integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
"dev": true,
"requires": {
"glob-to-regexp": "^0.4.1",
@ -26975,41 +26975,41 @@
"integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE="
},
"webpack": {
"version": "5.65.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.65.0.tgz",
"integrity": "sha512-Q5or2o6EKs7+oKmJo7LaqZaMOlDWQse9Tm5l1WAfU/ujLGN5Pb0SqGeVkN/4bpPmEqEP5RnVhiqsOtWtUVwGRw==",
"version": "5.76.0",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz",
"integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==",
"dev": true,
"requires": {
"@types/eslint-scope": "^3.7.0",
"@types/estree": "^0.0.50",
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^0.0.51",
"@webassemblyjs/ast": "1.11.1",
"@webassemblyjs/wasm-edit": "1.11.1",
"@webassemblyjs/wasm-parser": "1.11.1",
"acorn": "^8.4.1",
"acorn": "^8.7.1",
"acorn-import-assertions": "^1.7.6",
"browserslist": "^4.14.5",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.8.3",
"enhanced-resolve": "^5.10.0",
"es-module-lexer": "^0.9.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.4",
"json-parse-better-errors": "^1.0.2",
"graceful-fs": "^4.2.9",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^3.1.0",
"tapable": "^2.1.1",
"terser-webpack-plugin": "^5.1.3",
"watchpack": "^2.3.1",
"webpack-sources": "^3.2.2"
"watchpack": "^2.4.0",
"webpack-sources": "^3.2.3"
},
"dependencies": {
"enhanced-resolve": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz",
"integrity": "sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA==",
"version": "5.12.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz",
"integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==",
"dev": true,
"requires": {
"graceful-fs": "^4.2.4",
@ -27135,9 +27135,9 @@
"dev": true
},
"webpack-sources": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.2.tgz",
"integrity": "sha512-cp5qdmHnu5T8wRg2G3vZZHoJPN14aqQ89SyQ11NpGH5zEMDCclt49rzo+MaRazk7/UeILhAI+/sEtcM+7Fr0nw==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
"integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==",
"dev": true
},
"well-known-symbols": {

2
packages/nc-cli/package.json

@ -113,7 +113,7 @@
"tslint-config-prettier": "^1.18.0",
"tslint-immutable": "^6.0.1",
"typescript": "^3.5.3",
"webpack": "^5.1.0",
"webpack": "^5.76.0",
"webpack-cli": "^4.10.0",
"webpack-node-externals": "^2.5.2"
},

15
packages/nc-gui/app.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { applyNonSelectable, computed, useRoute, useTheme } from '#imports'
import { computed, useRoute, useTheme } from '#imports'
const route = useRoute()
@ -7,7 +7,18 @@ const disableBaseLayout = computed(() => route.path.startsWith('/nc/view') || ro
useTheme()
applyNonSelectable()
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (cmdOrCtrl) {
switch (e.key.toLowerCase()) {
case 'a':
// prevent Ctrl + A selection for non-editable nodes
if (!['input', 'textarea'].includes((e.target as any).nodeName.toLowerCase())) {
e.preventDefault()
}
}
}
})
// TODO: Remove when https://github.com/vuejs/core/issues/5513 fixed
const key = ref(0)

13
packages/nc-gui/assets/css/global.css

@ -30,15 +30,4 @@ For Drag and Drop
*/
.grabbing * {
cursor: grabbing;
}
/*
Prevent Ctrl + A selection
*/
.non-selectable {
-webkit-user-select: none;
-webkit-touch-callout: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}

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

@ -287,6 +287,10 @@ a {
@apply !shadow-none rounded hover:(ring-1 ring-primary ring-opacity-100) focus:(ring-1 ring-accent ring-opacity-100);
}
.nc-warning-info {
@apply !shadow-none rounded ring-1 ring-red-600
}
.ant-modal {
@apply !top-[50px];
}

42
packages/nc-gui/assets/style/fonts.css

@ -149,3 +149,45 @@
font-style: normal;
font-display: swap;
}
/* material-symbols-outlined-200 - latin */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 200;
src: url('./material-symbols/material-symbols-outlined-v92-latin-200.eot'); /* IE9 Compat Modes */
src: url('./material-symbols/material-symbols-outlined-v92-latin-200.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('./material-symbols/material-symbols-outlined-v92-latin-200.woff2') format('woff2'), /* Super Modern Browsers */
url('./material-symbols/material-symbols-outlined-v92-latin-200.woff') format('woff'), /* Modern Browsers */
url('./material-symbols/material-symbols-outlined-v92-latin-200.ttf') format('truetype'), /* Safari, Android, iOS */
url('./material-symbols/material-symbols-outlined-v92-latin-200.svg#MaterialSymbolsOutlined') format('svg'); /* Legacy iOS */
}
/* // href: 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,200,0,-25',
*/
/* fallback */
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 200;
src: url(./material.woff2) format('woff2');
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
font-size: 22px !important;
user-select: none;
}

BIN
packages/nc-gui/assets/style/material.woff2

Binary file not shown.

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

@ -89,6 +89,7 @@ declare module '@vue/runtime-core' {
IcTwotoneWidthNormal: typeof import('~icons/ic/twotone-width-normal')['default']
LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default']
LogosMysqlIcon: typeof import('~icons/logos/mysql-icon')['default']
LogosOracle: typeof import('~icons/logos/oracle')['default']
LogosPostgresql: typeof import('~icons/logos/postgresql')['default']
LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default']
LogosSnowflakeIcon: typeof import('~icons/logos/snowflake-icon')['default']
@ -103,6 +104,7 @@ declare module '@vue/runtime-core' {
MaterialSymbolsFileCopyOutline: typeof import('~icons/material-symbols/file-copy-outline')['default']
MaterialSymbolsKeyboardReturn: typeof import('~icons/material-symbols/keyboard-return')['default']
MaterialSymbolsLightModeOutline: typeof import('~icons/material-symbols/light-mode-outline')['default']
MaterialSymbolsMobileFriendly: typeof import('~icons/material-symbols/mobile-friendly')['default']
MaterialSymbolsRocketLaunchOutline: typeof import('~icons/material-symbols/rocket-launch-outline')['default']
MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default']
@ -135,6 +137,7 @@ declare module '@vue/runtime-core' {
MdiCardsHeart: typeof import('~icons/mdi/cards-heart')['default']
MdiCellphoneMessage: typeof import('~icons/mdi/cellphone-message')['default']
MdiChat: typeof import('~icons/mdi/chat')['default']
MdiChatProcessingOutline: typeof import('~icons/mdi/chat-processing-outline')['default']
MdiCheck: typeof import('~icons/mdi/check')['default']
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
@ -145,6 +148,7 @@ declare module '@vue/runtime-core' {
MdiCloseCircleOutline: typeof import('~icons/mdi/close-circle-outline')['default']
MdiCloseThick: typeof import('~icons/mdi/close-thick')['default']
MdiCodeJson: typeof import('~icons/mdi/code-json')['default']
MdiCodeScan: typeof import('~icons/mdi/code-scan')['default']
MdiCodeTags: typeof import('~icons/mdi/code-tags')['default']
MdiCog: typeof import('~icons/mdi/cog')['default']
MdiCommentTextOutline: typeof import('~icons/mdi/comment-text-outline')['default']
@ -186,6 +190,7 @@ declare module '@vue/runtime-core' {
MdiFunction: typeof import('~icons/mdi/function')['default']
MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default']
MdiGithub: typeof import('~icons/mdi/github')['default']
MdiGpsFixed: typeof import('~icons/mdi/gps-fixed')['default']
MdiGraphOutline: typeof import('~icons/mdi/graph-outline')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHook: typeof import('~icons/mdi/hook')['default']
@ -203,6 +208,8 @@ declare module '@vue/runtime-core' {
MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMapMarker: typeof import('~icons/mdi/map-marker')['default']
MdiMapMarkerAlert: typeof import('~icons/mdi/map-marker-alert')['default']
MdiMenu: typeof import('~icons/mdi/menu')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['default']
@ -215,9 +222,11 @@ declare module '@vue/runtime-core' {
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']
MdiQrcodeScan: typeof import('~icons/mdi/qrcode-scan')['default']
MdiReddit: typeof import('~icons/mdi/reddit')['default']
MdiRefresh: typeof import('~icons/mdi/refresh')['default']
MdiReload: typeof import('~icons/mdi/reload')['default']
MdiReset: typeof import('~icons/mdi/reset')['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']
@ -234,6 +243,7 @@ declare module '@vue/runtime-core' {
MdiTableColumnPlusBefore: typeof import('~icons/mdi/table-column-plus-before')['default']
MdiTableKey: typeof import('~icons/mdi/table-key')['default']
MdiTableLarge: typeof import('~icons/mdi/table-large')['default']
MdiTestTube: typeof import('~icons/mdi/test-tube')['default']
MdiText: typeof import('~icons/mdi/text')['default']
MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default']
MdiTrashCan: typeof import('~icons/mdi/trash-can')['default']
@ -249,8 +259,36 @@ declare module '@vue/runtime-core' {
NcIconsRowHeightMedium: typeof import('~icons/nc-icons/row-height-medium')['default']
NcIconsRowHeightShort: typeof import('~icons/nc-icons/row-height-short')['default']
NcIconsRowHeightTall: typeof import('~icons/nc-icons/row-height-tall')['default']
PhArrowClockwiseThin: typeof import('~icons/ph/arrow-clockwise-thin')['default']
PhAtThin: typeof import('~icons/ph/at-thin')['default']
PhBracketsAngleThin: typeof import('~icons/ph/brackets-angle-thin')['default']
PhBracketsCurlyThin: typeof import('~icons/ph/brackets-curly-thin')['default']
PhCaretDoubleLeftThin: typeof import('~icons/ph/caret-double-left-thin')['default']
PhCaretDoubleRightThin: typeof import('~icons/ph/caret-double-right-thin')['default']
PhCaretDoubleThin: typeof import('~icons/ph/caret-double-thin')['default']
PhCaretDownThin: typeof import('~icons/ph/caret-down-thin')['default']
PhChatTextThin: typeof import('~icons/ph/chat-text-thin')['default']
PhClockClockwiseThin: typeof import('~icons/ph/clock-clockwise-thin')['default']
PhCloudLightningDuotone: typeof import('~icons/ph/cloud-lightning-duotone')['default']
PhCloudLightningThin: typeof import('~icons/ph/cloud-lightning-thin')['default']
PhEyeThin: typeof import('~icons/ph/eye-thin')['default']
PhFileCsv: typeof import('~icons/ph/file-csv')['default']
PhFolderSimpleThin: typeof import('~icons/ph/folder-simple-thin')['default']
PhFolderThin: typeof import('~icons/ph/folder-thin')['default']
PhFunnelThin: typeof import('~icons/ph/funnel-thin')['default']
PhlistBulletsThin: typeof import('~icons/ph/list-bullets-thin')['default']
PhListBulletsThin: typeof import('~icons/ph/list-bullets-thin')['default']
PhMagnifyingGlassThin: typeof import('~icons/ph/magnifying-glass-thin')['default']
PhPlusThin: typeof import('~icons/ph/plus-thin')['default']
PhPresentationThin: typeof import('~icons/ph/presentation-thin')['default']
PhShareThin: typeof import('~icons/ph/share-thin')['default']
PhSignOutThin: typeof import('~icons/ph/sign-out-thin')['default']
PhSortAscendingThin: typeof import('~icons/ph/sort-ascending-thin')['default']
PhSplitVerticalThin: typeof import('~icons/ph/split-vertical-thin')['default']
PhTranslateThin: typeof import('~icons/ph/translate-thin')['default']
PhUserPlusThin: typeof import('~icons/ph/user-plus-thin')['default']
PhUsersThreeThin: typeof import('~icons/ph/users-three-thin')['default']
PhXCircleLight: typeof import('~icons/ph/x-circle-light')['default']
RiLineHeight: typeof import('~icons/ri/line-height')['default']
RiTeamFill: typeof import('~icons/ri/team-fill')['default']
RouterLink: typeof import('vue-router')['RouterLink']

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

@ -14,9 +14,8 @@ let key = $ref('')
const loadLicense = async () => {
try {
const response = await api.orgLicense.get()
key = response.key
} catch (e) {
key = response.key!
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
@ -25,7 +24,7 @@ const setLicense = async () => {
await api.orgLicense.set({ key: key })
message.success('License key updated')
await loadAppInfo()
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:license')
@ -45,5 +44,3 @@ loadLicense()
</div>
</div>
</template>
<style scoped></style>

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n } from '#imports'
import { iconMap, message, navigateTo, reactive, ref, useApi, useGlobal, useI18n } from '#imports'
const { api, error } = useApi({ useGlobalInstance: true })
@ -54,7 +54,7 @@ const passwordChange = async () => {
message.success(t('msg.success.passwordChanged'))
signOut()
await signOut()
navigateTo('/signin')
}
@ -121,7 +121,7 @@ const resetError = () => {
<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 />
<component :is="iconMap.passwordChange" />
{{ $t('activity.changePwd') }}
</span>
</button>

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

@ -12,7 +12,7 @@ const loadSettings = async () => {
try {
const response = await api.orgAppSettings.get()
settings = response
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
@ -21,7 +21,7 @@ const saveSettings = async () => {
try {
await api.orgAppSettings.set(settings)
message.success(t('msg.success.settingsSaved'))
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}

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

@ -1,7 +1,8 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import { Empty, Modal, message } from 'ant-design-vue'
import type { ApiTokenType, RequestParams, UserType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useApi, useCopy, useNuxtApp } from '#imports'
import { extractSdkResponseErrorMsg, iconMap, useApi, useCopy, useNuxtApp } from '#imports'
const { api, isLoading } = useApi()
@ -42,7 +43,7 @@ const loadTokens = async (page = currentPage, limit = currentLimit) => {
pagination.pageSize = 10
tokens = response.list as UserType[]
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
@ -55,11 +56,10 @@ const deleteToken = async (token: string) => {
type: 'warn',
onOk: async () => {
try {
// todo: delete token
await api.orgTokens.delete(token)
message.success(t('msg.success.tokenDeleted'))
await loadTokens()
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:account:token:delete')
@ -75,7 +75,7 @@ const generateToken = async () => {
message.success(t('msg.success.tokenGenerated'))
selectedTokenData = {}
await loadTokens()
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:generate')
@ -90,14 +90,12 @@ const copyToken = async (token: string | undefined) => {
message.info(t('msg.info.copiedToClipboard'))
$e('c:api-token:copy')
} catch (e) {
} catch (e: any) {
message.error(e.message)
}
}
const descriptionInput = (el) => {
el?.focus()
}
const descriptionInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script>
<template>
@ -106,10 +104,10 @@ const descriptionInput = (el) => {
<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" />
<component :is="iconMap.reload" 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 />
<component :is="iconMap.plus" />
Add new token
</div>
</a-button>
@ -177,7 +175,7 @@ const descriptionInput = (el) => {
<a-button type="text" class="!rounded-md" @click="copyToken(record.token)">
<template #icon>
<MdiContentCopy class="flex mx-auto h-[1rem]" />
<component :is="iconMap.copy" class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
@ -200,7 +198,7 @@ const descriptionInput = (el) => {
<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" />
<component :is="iconMap.delete" class="flex" />
<div class="text-xs pl-2">{{ $t('general.remove') }}</div>
</div>
</a-menu-item>

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

@ -1,7 +1,7 @@
<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 { OrgUserReqType, RequestParams, UserType } from 'nocodb-sdk'
import { Role, extractSdkResponseErrorMsg, iconMap, useApi, useCopy, useDashboard, useNuxtApp } from '#imports'
import type { User } from '~/lib'
const { api, isLoading } = useApi()
@ -42,13 +42,15 @@ const loadUsers = async (page = currentPage, limit = currentLimit) => {
query: searchText.value,
},
} as RequestParams)
if (!response) return
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
users = response.list as UserType[]
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
@ -59,11 +61,11 @@ const updateRole = async (userId: string, roles: Role) => {
try {
await api.orgUsers.update(userId, {
roles,
} as unknown as UserType)
} as OrgUserReqType)
message.success(t('msg.success.roleUpdated'))
$e('a:org-user:role-updated', { role: roles })
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
@ -78,7 +80,7 @@ const deleteUser = async (userId: string) => {
message.success(t('msg.success.userDeleted'))
await loadUsers()
$e('a:org-user:user-deleted')
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
},
@ -92,7 +94,7 @@ const resendInvite = async (user: User) => {
// Invite email sent successfully
message.success(t('msg.success.inviteEmailSent'))
await loadUsers()
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
@ -106,7 +108,7 @@ const copyInviteUrl = async (user: User) => {
// Invite URL copied to clipboard
message.success(t('msg.success.inviteURLCopied'))
} catch (e) {
} catch (e: any) {
message.error(e.message)
}
$e('c:user:copy-url')
@ -121,7 +123,7 @@ const copyPasswordResetUrl = async (user: User) => {
// Invite URL copied to clipboard
message.success(t('msg.success.passwordResetURLCopied'))
$e('c:user:copy-url')
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
@ -142,7 +144,7 @@ const copyPasswordResetUrl = async (user: User) => {
>
</a-input-search>
<div class="flex-grow"></div>
<MdiReload class="cursor-pointer" @click="loadUsers" />
<component :is="iconMap.reload" class="cursor-pointer" @click="loadUsers" />
<a-button
data-testid="nc-super-user-invite"
size="small"
@ -155,7 +157,7 @@ const copyPasswordResetUrl = async (user: User) => {
"
>
<div class="flex items-center gap-1">
<MdiAdd />
<component :is="iconMap.plus" />
Invite new user
</div>
</a-button>
@ -237,7 +239,7 @@ const copyPasswordResetUrl = async (user: User) => {
<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" />
<component :is="iconMap.threeDotHorizontal" class="nc-user-row-action" />
</div>
</a-button>
</div>
@ -248,26 +250,26 @@ const copyPasswordResetUrl = async (user: User) => {
<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" />
<component :is="iconMap.email" 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" />
<component :is="iconMap.copy" 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" />
<component :is="iconMap.copy" 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" />
<component :is="iconMap.delete" 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>

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

@ -1,16 +1,18 @@
<script setup lang="ts">
import type { UserType } from 'nocodb-sdk'
import type { VNodeRef } from '@vue/runtime-core'
import type { OrgUserReqType } from 'nocodb-sdk'
import {
Form,
computed,
emailValidator,
extractSdkResponseErrorMsg,
iconMap,
message,
ref,
useCopy,
useDashboard,
useI18n,
useNuxtApp,
validateEmail,
} from '#imports'
import type { User } from '~/lib'
import { Role } from '~/lib'
@ -43,24 +45,10 @@ const usersData = $ref<Users>({ emails: '', role: Role.OrgLevelViewer, invitatio
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()
}
},
},
],
emails: [emailValidator],
}
})
@ -72,11 +60,10 @@ const saveUser = async () => {
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)
} as unknown as OrgUserReqType)
usersData.invitationToken = res.invite_token
emit('reload')
@ -98,7 +85,7 @@ const copyUrl = async () => {
// Copied shareable base url to clipboard!
message.success(t('msg.success.shareableURLCopied'))
} catch (e) {
} catch (e: any) {
message.error(e.message)
}
$e('c:shared-base:copy-url')
@ -110,9 +97,8 @@ const clickInviteMore = () => {
usersData.role = Role.OrgLevelViewer
usersData.emails = ''
}
const emailInput = ref((el) => {
el?.focus()
})
const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script>
<template>
@ -141,7 +127,7 @@ const emailInput = ref((el) => {
<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 />
<component :is="iconMap.account" />
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div>
</div>
@ -154,7 +140,7 @@ const emailInput = ref((el) => {
<a-button type="text" class="!rounded-md -mt-0.5" @click="copyUrl">
<template #icon>
<MdiContentCopy class="flex mx-auto text-green-700 h-[1rem]" />
<component :is="iconMap.copy" class="flex mx-auto text-green-700 h-[1rem]" />
</template>
</a-button>
</div>
@ -180,7 +166,7 @@ const emailInput = ref((el) => {
<div v-else class="flex flex-col pb-4">
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]">
<MdiAccountOutline />
<component :is="iconMap.account" />
<div class="text-xs ml-0.5 mt-0.5">{{ $t('activity.inviteUser') }}</div>
</div>

6
packages/nc-gui/components/api-client/Headers.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useVModel } from '#imports'
import { iconMap, useVModel } from '#imports'
const props = defineProps<{
modelValue: any[]
@ -116,7 +116,7 @@ const filterOption = (input: string, option: Option) => {
<td class="relative">
<div v-if="idx !== 0" class="absolute flex flex-col justify-start mt-2 -right-6 top-0">
<MdiDeleteOutline class="cursor-pointer" @click="deleteHeaderRow(idx)" />
<component :is="iconMap.delete" class="cursor-pointer" @click="deleteHeaderRow(idx)" />
</div>
</td>
</tr>
@ -125,7 +125,7 @@ const filterOption = (input: string, option: Option) => {
<td :colspan="12" class="text-center">
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1" @click="addHeaderRow">
<template #icon>
<MdiPlus class="flex mx-auto" />
<component :is="iconMap.plus" class="flex mx-auto" />
</template>
</a-button>
</td>

6
packages/nc-gui/components/api-client/Params.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { useVModel } from '#imports'
import { iconMap, useVModel } from '#imports'
const props = defineProps<{
modelValue: any[]
@ -59,7 +59,7 @@ const deleteParamRow = (i: number) => vModel.value.splice(i, 1)
<td class="relative">
<div v-if="idx !== 0" class="absolute flex flex-col justify-start mt-2 -right-6 top-0">
<MdiDeleteOutline class="cursor-pointer" @click="deleteParamRow(idx)" />
<component :is="iconMap.delete" class="cursor-pointer" @click="deleteParamRow(idx)" />
</div>
</td>
</tr>
@ -68,7 +68,7 @@ const deleteParamRow = (i: number) => vModel.value.splice(i, 1)
<td :colspan="12" class="text-center">
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1" @click="addParamRow">
<template #icon>
<MdiPlus class="flex mx-auto" />
<component :is="iconMap.plus" class="flex mx-auto" />
</template>
</a-button>
</td>

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

@ -1,5 +1,14 @@
<script setup lang="ts">
import { ActiveCellInj, ColumnInj, IsFormInj, ReadonlyInj, getMdiIcon, inject, useSelectedCellKeyupListener } from '#imports'
import {
ActiveCellInj,
ColumnInj,
IsFormInj,
ReadonlyInj,
getMdiIcon,
inject,
parseProp,
useSelectedCellKeyupListener,
} from '#imports'
interface Props {
// If the previous cell value was a text, the initial checkbox value is a string type
@ -35,7 +44,7 @@ const checkboxMeta = $computed(() => {
unchecked: 'mdi-checkbox-blank-circle-outline',
},
color: 'primary',
...(column?.value?.meta || {}),
...parseProp(column?.value?.meta),
}
})
@ -89,7 +98,7 @@ useSelectedCellKeyupListener(active, (e) => {
<style scoped lang="scss">
.nc-cell-hover-show {
opacity: 0;
opacity: 0.3;
transition: 0.3s opacity;
&:hover {

4
packages/nc-gui/components/cell/ClampedText.vue

@ -29,9 +29,9 @@ onMounted(() => {
-->
<text-clamp
:key="`clamp-${key}-${props.value?.toString().length || 0}`"
class="w-full h-full break-all"
class="w-full h-full break-word"
:text="`${props.value || ' '}`"
:max-lines="props.lines"
:max-lines="props.lines || 1"
/>
</div>
</template>

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { ColumnInj, EditModeInj, computed, inject, useVModel } from '#imports'
import { ColumnInj, EditModeInj, computed, inject, parseProp, useVModel } from '#imports'
interface Props {
modelValue: number | null | undefined
@ -35,7 +35,7 @@ const currencyMeta = computed(() => {
return {
currency_locale: 'en-US',
currency_code: 'USD',
...(column.value.meta ? column.value.meta : {}),
...parseProp(column?.value?.meta),
}
})
@ -81,6 +81,7 @@ onMounted(() => {
@keydown.delete.stop
@selectstart.capture.stop
@mousedown.stop
@contextmenu.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null">NULL</span>

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

@ -8,6 +8,7 @@ import {
computed,
inject,
isDrawerOrModalExist,
parseProp,
ref,
useSelectedCellKeyupListener,
watch,
@ -34,7 +35,7 @@ const editable = inject(EditModeInj, ref(false))
let isDateInvalid = $ref(false)
const dateFormat = $computed(() => columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD')
const dateFormat = $computed(() => parseProp(columnMeta?.value?.meta)?.date_format ?? 'YYYY-MM-DD')
let localState = $computed({
get() {

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

@ -7,6 +7,7 @@ import {
dateFormats,
inject,
isDrawerOrModalExist,
parseProp,
ref,
timeFormats,
useProject,
@ -38,8 +39,8 @@ const column = inject(ColumnInj)!
let isDateInvalid = $ref(false)
const dateTimeFormat = $computed(() => {
const dateFormat = column?.value?.meta?.date_format ?? dateFormats[0]
const timeFormat = column?.value?.meta?.time_format ?? timeFormats[0]
const dateFormat = parseProp(column?.value?.meta)?.date_format ?? dateFormats[0]
const timeFormat = parseProp(column?.value?.meta)?.time_format ?? timeFormats[0]
return `${dateFormat} ${timeFormat}`
})

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

@ -3,6 +3,9 @@ import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, inject, useVModel } from '#imports'
interface Props {
// when we set a number, then it is number type
// for sqlite, when we clear a cell or empty the cell, it returns ""
// otherwise, it is null type
modelValue?: number | null | string
}
@ -22,8 +25,10 @@ const _vModel = useVModel(props, 'modelValue', emits)
const vModel = computed({
get: () => _vModel.value,
set: (value: string) => {
set: (value) => {
if (value === '') {
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null
} else {
_vModel.value = value

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

@ -8,6 +8,7 @@ import {
convertMS2Duration,
durationOptions,
inject,
parseProp,
ref,
} from '#imports'
@ -32,7 +33,7 @@ const durationInMS = ref(0)
const isEdited = ref(false)
const durationType = computed(() => column?.value?.meta?.duration || 0)
const durationType = computed(() => parseProp(column?.value?.meta)?.duration || 0)
const durationPlaceholder = computed(() => durationOptions[durationType.value].title)

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

@ -1,28 +1,53 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, computed, inject, useVModel, validateEmail } from '#imports'
import { EditModeInj, IsSurveyFormInj, computed, inject, useI18n, validateEmail } from '#imports'
interface Props {
modelValue: string | null | undefined
}
interface Emits {
(event: 'update:modelValue', model: string): void
}
const { modelValue: value } = defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const emits = defineEmits<Emits>()
const { t } = useI18n()
const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj)
const editEnabled = inject(EditModeInj)!
const column = inject(ColumnInj)!
// Used in the logic of when to display error since we are not storing the email if it's not valid
const localState = ref(value)
const vModel = useVModel(props, 'modelValue', emits)
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isSurveyForm.value) {
emit('update:modelValue', val)
}
},
})
const validEmail = computed(() => vModel.value && validateEmail(vModel.value))
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
() => {
if (parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !validateEmail(localState.value)) {
message.error(t('msg.error.invalidEmail'))
localState.value = undefined
return
}
localState.value = value
},
)
</script>
<template>
@ -30,7 +55,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none text-sm px-2"
class="w-full outline-none text-sm px-2"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop

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

@ -3,7 +3,10 @@ import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, inject, useVModel } from '#imports'
interface Props {
modelValue?: number | null
// when we set a number, then it is number type
// for sqlite, when we clear a cell or empty the cell, it returns ""
// otherwise, it is null type
modelValue?: number | null | string
}
interface Emits {
@ -22,8 +25,10 @@ const _vModel = useVModel(props, 'modelValue', emits)
const vModel = computed({
get: () => _vModel.value,
set: (value: string) => {
set: (value) => {
if (value === '') {
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null
} else {
_vModel.value = value

186
packages/nc-gui/components/cell/GeoData.vue

@ -0,0 +1,186 @@
<script lang="ts" setup>
import type { GeoLocationType } from 'nocodb-sdk'
import { Modal as AModal, iconMap, latLongToJoinedString, useVModel } from '#imports'
interface Props {
modelValue?: string | null
}
interface Emits {
(event: 'update:modelValue', model: GeoLocationType): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'modelValue', emits)
let isExpanded = $ref(false)
let isLoading = $ref(false)
let isLocationSet = $ref(false)
const [latitude, longitude] = (vModel.value || '').split(';')
const latLongStr = computed(() => {
const [latitude, longitude] = (vModel.value || '').split(';')
if (latitude) isLocationSet = true
return latitude && longitude ? `${latitude}; ${longitude}` : 'Set location'
})
const formState = reactive({
latitude,
longitude,
})
const handleFinish = () => {
vModel.value = latLongToJoinedString(parseFloat(formState.latitude), parseFloat(formState.longitude))
isExpanded = false
}
const clear = () => {
isExpanded = false
formState.latitude = latitude
formState.longitude = longitude
}
const onClickSetCurrentLocation = () => {
isLoading = true
const onSuccess: PositionCallback = (position: GeolocationPosition) => {
const crd = position.coords
formState.latitude = `${crd.latitude}`
formState.longitude = `${crd.longitude}`
isLoading = false
}
const onError: PositionErrorCallback = (err: GeolocationPositionError) => {
console.error(`ERROR(${err.code}): ${err.message}`)
isLoading = false
}
const options = {
enableHighAccuracy: true,
timeout: 20000,
maximumAge: 2000,
}
navigator.geolocation.getCurrentPosition(onSuccess, onError, options)
}
const openInGoogleMaps = () => {
const [latitude, longitude] = (vModel.value || '').split(';')
const url = `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`
window.open(url, '_blank')
}
const openInOSM = () => {
const [latitude, longitude] = (vModel.value || '').split(';')
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`
window.open(url, '_blank')
}
</script>
<template>
<a-dropdown :is="isExpanded ? AModal : 'div'" v-model:visible="isExpanded" trigger="click">
<div
v-if="!isLocationSet"
class="group cursor-pointer flex gap-1 items-center mx-auto max-w-64 justify-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)"
>
<div class="flex items-center gap-2" data-testid="nc-geo-data-set-location-button">
<component
:is="iconMap.mapMarker"
class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-[0.75rem]"
/>
<div class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-xs">
{{ latLongStr }}
</div>
</div>
</div>
<div v-else data-testid="nc-geo-data-lat-long-set">{{ latLongStr }}</div>
<template #overlay>
<a-form :model="formState" class="flex flex-col w-max-64" @finish="handleFinish">
<a-form-item>
<div class="flex mt-4 items-center mx-2">
<div class="mr-2">{{ $t('labels.lat') }}:</div>
<a-input
v-model:value="formState.latitude"
data-testid="nc-geo-data-latitude"
type="number"
step="0.0000001"
:min="-90"
required
:max="90"
@keydown.stop
@selectstart.capture.stop
@mousedown.stop
/>
</div>
</a-form-item>
<a-form-item>
<div class="flex items-center mx-2">
<div class="mr-2">{{ $t('labels.lng') }}:</div>
<a-input
v-model:value="formState.longitude"
data-testid="nc-geo-data-longitude"
type="number"
step="0.0000001"
required
:min="-180"
:max="180"
@keydown.stop
@selectstart.capture.stop
@mousedown.stop
/>
</div>
</a-form-item>
<a-form-item>
<div class="mr-2 flex flex-col items-end gap-1 text-left">
<component
:is="iconMap.reload"
v-if="isLoading"
:class="{ 'animate-infinite animate-spin text-gray-500': isLoading }"
/>
<a-button class="ml-2" @click="onClickSetCurrentLocation"
><component :is="iconMap.currentLocation" class="mr-2" />{{ $t('labels.currentLocation') }}</a-button
>
</div>
</a-form-item>
<a-form-item v-if="vModel">
<div class="mr-2 flex flex-row items-end gap-1 text-left">
<a-button @click="openInOSM"
><component :is="iconMap.openInNew" class="mr-2" />{{ $t('activity.map.openInOpenStreetMap') }}</a-button
>
<a-button @click="openInGoogleMaps"
><component :is="iconMap.openInNew" class="mr-2" />{{ $t('activity.map.openInGoogleMaps') }}</a-button
>
</div>
</a-form-item>
<a-form-item>
<div class="ml-auto mr-2 w-auto">
<a-button type="text" @click="clear">{{ $t('general.cancel') }}</a-button>
<a-button type="primary" html-type="submit" data-testid="nc-geo-data-save">{{ $t('general.submit') }}</a-button>
</div>
</a-form-item>
</a-form>
</template>
</a-dropdown>
</template>
<style scoped lang="scss">
input[type='number']:focus {
@apply ring-transparent;
}
input[type='number'] {
width: 180px;
}
.ant-form-item {
margin-bottom: 1rem;
}
.ant-dropdown-menu {
align-items: flex-end;
}
</style>

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

@ -3,7 +3,10 @@ import type { VNodeRef } from '@vue/runtime-core'
import { EditModeInj, inject, useVModel } from '#imports'
interface Props {
modelValue?: number | null
// when we set a number, then it is number type
// for sqlite, when we clear a cell or empty the cell, it returns ""
// otherwise, it is null type
modelValue?: number | null | string
}
interface Emits {
@ -22,8 +25,10 @@ const _vModel = useVModel(props, 'modelValue', emits)
const vModel = computed({
get: () => _vModel.value,
set: (value: string) => {
set: (value) => {
if (value === '') {
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null
} else {
_vModel.value = value

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

@ -14,6 +14,7 @@ import {
enumColor,
extractSdkResponseErrorMsg,
h,
iconMap,
inject,
isDrawerOrModalExist,
onMounted,
@ -32,6 +33,7 @@ interface Props {
modelValue?: string | string[]
rowIndex?: number
disableOptionCreation?: boolean
location?: 'cell' | 'filter'
}
const { modelValue, disableOptionCreation } = defineProps<Props>()
@ -259,8 +261,7 @@ async function addIfMissingAndSave() {
} else {
activeOptCreateInProgress.value--
}
} catch (e) {
// todo: handle error
} catch (e: any) {
console.log(e)
activeOptCreateInProgress.value--
message.error(await extractSdkResponseErrorMsg(e))
@ -282,7 +283,7 @@ const onTagClick = (e: Event, onClose: Function) => {
}
}
const cellClickHook = inject(CellClickHookInj)
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => {
if (cellClickHook) return
@ -336,7 +337,7 @@ useEventListener(document, 'click', handleClose, true)
v-for="op of options"
:key="op.id || op.title"
:value="op.title"
:data-testid="`select-option-${column.title}-${rowIndex}`"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.title}`"
@click.stop
>
@ -367,7 +368,7 @@ useEventListener(document, 'click', handleClose, true)
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<component :is="iconMap.plusThick" class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ActiveCellInj, ColumnInj, computed, inject, useSelectedCellKeyupListener } from '#imports'
import { ActiveCellInj, ColumnInj, computed, inject, parseProp, useSelectedCellKeyupListener } from '#imports'
interface Props {
modelValue?: number | null | undefined
@ -19,7 +19,7 @@ const ratingMeta = computed(() => {
},
color: '#fcb401',
max: 5,
...(column.value?.meta || {}),
...parseProp(column.value?.meta),
}
})

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

@ -15,10 +15,12 @@ import {
computed,
enumColor,
extractSdkResponseErrorMsg,
iconMap,
inject,
isDrawerOrModalExist,
ref,
useEventListener,
useProject,
useRoles,
useSelectedCellKeyupListener,
watch,
@ -207,7 +209,7 @@ const onSelect = () => {
isOpen.value = false
}
const cellClickHook = inject(CellClickHookInj)
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = (e: Event) => {
// todo: refactor
@ -247,12 +249,12 @@ useEventListener(document, 'click', handleClose, true)
<a-select
ref="aselect"
v-model:value="vModel"
class="w-full"
class="w-full overflow-hidden"
:class="{ 'caret-transparent': !hasEditRoles }"
:allow-clear="!column.rqd && editAllowed"
:bordered="false"
:open="isOpen && (active || editable)"
:disabled="readOnly || !(active || editable)"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))"
:dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen && (active || editable) ? 'active' : ''}`"
:show-search="isOpen && (active || editable)"
@ -294,7 +296,7 @@ useEventListener(document, 'click', handleClose, true)
:value="searchVal"
>
<div class="flex gap-2 text-gray-500 items-center h-full">
<MdiPlusThick class="min-w-4" />
<component :is="iconMap.plusThick" class="min-w-4" />
<div class="text-xs whitespace-normal">
Create new option named <strong>{{ searchVal }}</strong>
</div>
@ -328,6 +330,12 @@ useEventListener(document, 'click', handleClose, true)
@apply !px-0;
}
:deep(.ant-select-selection-search) {
// following a-select with mode = multiple | tags
// initial width will block @mouseover in Grid.vue
@apply !w-[5px];
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
}

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

@ -14,6 +14,11 @@ const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const readonly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
@ -42,5 +47,5 @@ const focus: VNodeRef = (el) => {
<span v-else-if="vModel === null && showNull" class="nc-null">NULL</span>
<LazyCellClampedText v-else :value="vModel" :lines="1" />
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" />
</template>

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

@ -10,7 +10,10 @@ const emits = defineEmits(['update:modelValue'])
const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const { showNull } = useGlobal()
@ -45,3 +48,9 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
<span v-else>{{ vModel }}</span>
</template>
<style>
textarea:focus {
box-shadow: none;
}
</style>

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

@ -95,7 +95,6 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
<template>
<a-time-picker
v-model:value="localState"
autofocus
:show-time="true"
:bordered="false"
use12-hours

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

@ -4,10 +4,12 @@ import {
CellUrlDisableOverlayInj,
ColumnInj,
EditModeInj,
IsSurveyFormInj,
computed,
inject,
isValidURL,
message,
parseProp,
ref,
useCellUrlConfig,
useI18n,
@ -35,11 +37,13 @@ const disableOverlay = inject(CellUrlDisableOverlayInj, ref(false))
// Used in the logic of when to display error since we are not storing the url if it's not valid
const localState = ref(value)
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!column.value.meta?.validate || (val && isValidURL(val)) || !val) {
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(val)) || !val || isSurveyForm.value) {
emit('update:modelValue', val)
}
},
@ -63,7 +67,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
() => {
if (column.value.meta?.validate && !editEnabled.value && localState.value && !isValidURL(localState.value)) {
if (parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !isValidURL(localState.value)) {
message.error(t('msg.error.invalidURL'))
localState.value = undefined
return
@ -118,7 +122,7 @@ watch(
<div v-if="column.meta?.validate && !isValid && value?.length && !editEnabled" class="mr-1 w-1/10">
<a-tooltip placement="top">
<template #title> Invalid URL </template>
<template #title> {{ t('msg.error.invalidURL') }} </template>
<div class="flex flex-row items-center">
<MiCircleWarning class="text-red-400 h-4" />
</div>
@ -126,6 +130,3 @@ watch(
</div>
</div>
</template>
<!--
-->

8
packages/nc-gui/components/cell/attachment/Carousel.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { onKeyDown } from '@vueuse/core'
import { useAttachmentCell } from './utils'
import { computed, isImage, onClickOutside, ref, useAttachment } from '#imports'
import { computed, iconMap, isImage, onClickOutside, ref, useAttachment } from '#imports'
const { selectedImage, visibleItems, downloadFile } = useAttachmentCell()!
@ -53,7 +53,11 @@ onClickOutside(carouselRef, () => {
<template v-if="selectedImage">
<div class="overflow-hidden p-12 text-center relative">
<div class="text-white group absolute top-5 right-5">
<MdiCloseCircle class="group-hover:text-red-500 cursor-pointer text-4xl" @click.stop="selectedImage = false" />
<component
:is="iconMap.closeCircle"
class="group-hover:text-red-500 cursor-pointer text-4xl"
@click.stop="selectedImage = false"
/>
</div>
<div

2
packages/nc-gui/components/cell/attachment/Image.vue

@ -21,5 +21,5 @@ const onError = () => index.value++
quality="75"
@error="onError"
/>
<MdiFileImageBox v-else />
<component :is="iconMap.imagePlaceholder" v-else />
</template>

9
packages/nc-gui/components/cell/attachment/Modal.vue

@ -2,7 +2,7 @@
import { onKeyDown } from '@vueuse/core'
import { useAttachmentCell } from './utils'
import { useSortable } from './sort'
import { isImage, ref, useAttachment, useDropZone, useUIPermission, watch } from '#imports'
import { iconMap, isImage, ref, useAttachment, useDropZone, useUIPermission, watch } from '#imports'
const { isUIAllowed } = useUIPermission()
@ -133,7 +133,8 @@ function onRemoveFileClick(title: any, i: number) {
<a-tooltip v-if="!readOnly">
<template #title> Remove File </template>
<MdiCloseCircle
<component
:is="iconMap.closeCircle"
v-if="isSharedForm || (isUIAllowed('tableAttachment') && !isPublic && !isLocked)"
class="nc-attachment-remove"
@click.stop="onRemoveFileClick(item.title, i)"
@ -144,7 +145,7 @@ function onRemoveFileClick(title: any, i: number) {
<template #title> Download File </template>
<div class="nc-attachment-download group-hover:(opacity-100)">
<MdiDownload @click.stop="downloadFile(item)" />
<component :is="iconMap.download" @click.stop="downloadFile(item)" />
</div>
</a-tooltip>
@ -155,7 +156,7 @@ function onRemoveFileClick(title: any, i: number) {
<template #title> Rename File </template>
<div class="nc-attachment-download group-hover:(opacity-100) mr-[35px]">
<MdiEditOutline @click.stop="renameFile(item, i)" />
<component :is="iconMap.edit" @click.stop="renameFile(item, i)" />
</div>
</a-tooltip>

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

@ -7,6 +7,7 @@ import {
DropZoneRef,
IsGalleryInj,
IsKanbanInj,
iconMap,
inject,
isImage,
nextTick,
@ -188,7 +189,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
data-testid="attachment-cell-file-picker-button"
@click.stop="open"
>
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<a-tooltip v-else placement="bottom">
<template #title> Click or drop a file into cell</template>
@ -238,12 +239,13 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
<div
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)"
>
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<a-tooltip v-else placement="bottom">
<template #title> View attachments</template>
<MdiArrowExpand
<component
:is="iconMap.expand"
class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-[0.75rem]"
@click.stop="modalVisible = true"
/>

6
packages/nc-gui/components/cell/attachment/utils.ts

@ -12,7 +12,9 @@ import {
inject,
isImage,
message,
parseProp,
ref,
storeToRefs,
useApi,
useAttachment,
useFileDialog,
@ -51,7 +53,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
/** for image carousel */
const selectedImage = ref()
const { project } = useProject()
const { project } = storeToRefs(useProject())
const { api, isLoading } = useApi()
@ -101,7 +103,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const attachmentMeta = {
...defaultAttachmentMeta,
...(typeof column.value?.meta === 'string' ? JSON.parse(column.value.meta) : column.value?.meta),
...parseProp(column?.value?.meta),
}
const newAttachments = []

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

@ -12,11 +12,14 @@ import {
TabType,
computed,
extractSdkResponseErrorMsg,
iconMap,
isDrawerOrModalExist,
isMac,
parseProp,
reactive,
ref,
resolveComponent,
storeToRefs,
useDialog,
useGlobal,
useNuxtApp,
@ -26,18 +29,22 @@ import {
useTabs,
useToggle,
useUIPermission,
useUndoRedo,
watchEffect,
} from '#imports'
import MdiView from '~icons/mdi/eye-circle-outline'
import MdiTableLarge from '~icons/mdi/table-large'
const { isMobileMode } = useGlobal()
const { addTab, updateTab } = useTabs()
const { $api, $e } = useNuxtApp()
const { bases, tables, loadTables, isSharedBase } = useProject()
const projectStore = useProject()
const { loadTables } = projectStore
const { bases, tables, isSharedBase, project } = storeToRefs(projectStore)
const { activeTab } = useTabs()
const { activeTab } = storeToRefs(useTabs())
const { deleteTable } = useTable()
@ -49,6 +56,8 @@ const [searchActive, toggleSearchActive] = useToggle()
const { appInfo } = useGlobal()
const { addUndo, defineProjectScope } = useUndoRedo()
const toggleDialog = inject(ToggleDialogInj, () => {})
const keys = $ref<Record<string, number>>({})
@ -84,13 +93,14 @@ const initSortable = (el: Element) => {
if (sortables[base_id]) sortables[base_id].destroy()
Sortable.create(el as HTMLLIElement, {
onEnd: async (evt) => {
const offset = tables.value.findIndex((table) => table.base_id === base_id)
const { newIndex = 0, oldIndex = 0 } = evt
const itemEl = evt.item as HTMLLIElement
const item = tablesById[itemEl.dataset.id as string]
// store the old order for undo
const oldOrder = item.order
// get the html collection of all list items
const children: HTMLCollection = evt.to.children
@ -114,8 +124,19 @@ const initSortable = (el: Element) => {
item.order = ((itemBefore.order as number) + (itemAfter.order as number)) / 2
}
// update the order of the moved item
tables.value?.splice(newIndex + offset, 0, ...tables.value?.splice(oldIndex + offset, 1))
// find the index of the moved item
const itemIndex = tables.value?.findIndex((table) => table.id === item.id)
// move the item to the new position
if (itemBefore) {
// find the index of the item before the moved item
const itemBeforeIndex = tables.value?.findIndex((table) => table.id === itemBefore.id)
tables.value?.splice(itemBeforeIndex + (newIndex > oldIndex ? 0 : 1), 0, ...tables.value?.splice(itemIndex, 1))
} else {
// if the item before is undefined (moving item to first slot), then find the index of the item after the moved item
const itemAfterIndex = tables.value?.findIndex((table) => table.id === itemAfter.id)
tables.value?.splice(itemAfterIndex, 0, ...tables.value?.splice(itemIndex, 1))
}
// force re-render the list
if (keys[base_id]) {
@ -128,6 +149,38 @@ const initSortable = (el: Element) => {
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
const nextIndex = tables.value?.findIndex((table) => table.id === item.id)
addUndo({
undo: {
fn: async (id: string, order: number, index: number) => {
const itemIndex = tables.value.findIndex((table) => table.id === id)
if (itemIndex < 0) return
const item = tables.value[itemIndex]
item.order = order
tables.value?.splice(index, 0, ...tables.value?.splice(itemIndex, 1))
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
},
args: [item.id, oldOrder, itemIndex],
},
redo: {
fn: async (id: string, order: number, index: number) => {
const itemIndex = tables.value.findIndex((table) => table.id === id)
if (itemIndex < 0) return
const item = tables.value[itemIndex]
item.order = order
tables.value?.splice(index, 0, ...tables.value?.splice(itemIndex, 1))
await $api.dbTable.reorder(item.id as string, {
order: item.order,
})
},
args: [item.id, item.order, nextIndex],
},
scope: defineProjectScope({ project: project.value }),
})
},
animation: 150,
})
@ -145,10 +198,10 @@ watchEffect(() => {
const icon = (table: TableType) => {
if (table.type === 'table') {
return MdiTableLarge
return iconMap.table
}
if (table.type === 'view') {
return MdiView
return iconMap.view
}
}
@ -290,16 +343,28 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
watch(
activeTable,
(value, oldValue) => {
let tableTitle
if (value) {
if (value !== oldValue) {
const fndTable = tables.value.find((el) => el.id === value)
if (fndTable) {
activeKey.value = [`collapse-${fndTable.base_id}`]
tableTitle = fndTable.title
}
}
} else {
if (bases.value.filter((el) => el.enabled)[0]?.id)
activeKey.value = [`collapse-${bases.value.filter((el) => el.enabled)[0].id}`]
const table = bases.value.filter((el) => el.enabled)[0]
if (table?.id) {
activeKey.value = [`collapse-${table.id}`]
}
if (table?.title) {
tableTitle = table.title
}
}
if (project.value.title && tableTitle) {
document.title = `${project.value.title}: ${tableTitle} | NocoDB`
} else {
document.title = 'NocoDB'
}
},
{ immediate: true },
@ -308,7 +373,7 @@ watch(
const setIcon = async (icon: string, table: TableType) => {
try {
table.meta = {
...(table.meta || {}),
...parseProp(table.meta),
icon,
}
tables.value.splice(tables.value.indexOf(table), 1, { ...table })
@ -320,7 +385,7 @@ const setIcon = async (icon: string, table: TableType) => {
})
$e('a:table:icon:navdraw', { icon })
} catch (e) {
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
@ -354,8 +419,13 @@ const setIcon = async (icon: string, table: TableType) => {
</Transition>
<Transition name="layout" mode="out-in">
<MdiClose v-if="searchActive" class="text-gray-500 text-lg mx-1 mt-0.5" @click="onSearchCloseIconClick" />
<IcRoundSearch v-else class="text-gray-500 text-lg mx-1 mt-0.5" @click="toggleSearchActive(true)" />
<GeneralIcon
v-if="searchActive"
icon="close"
class="text-gray-500 text-lg mx-1 mt-0.5"
@click="onSearchCloseIconClick"
/>
<GeneralIcon v-else icon="search" class="text-gray-500 text-lg mx-1 mt-0.5" @click="toggleSearchActive(true)" />
</Transition>
</div>
<div
@ -381,13 +451,18 @@ const setIcon = async (icon: string, table: TableType) => {
</Transition>
<Transition name="slide-right" mode="out-in">
<MdiClose v-if="searchActive" class="text-gray-500 text-lg mx-1 mt-0.5" @click="onSearchCloseIconClick" />
<IcRoundSearch v-else class="text-gray-500 text-lg mx-1 mt-0.5" @click="onSearchIconClick" />
<GeneralIcon
v-if="searchActive"
icon="close"
class="text-gray-500 text-lg mx-1 mt-0.5"
@click="onSearchCloseIconClick"
/>
<component :is="iconMap.search" v-else class="text-gray-500 text-lg mx-1 mt-0.5" @click="onSearchIconClick" />
</Transition>
<a-dropdown v-if="!isSharedBase" :trigger="['click']" overlay-class-name="nc-dropdown-import-menu" @click.stop>
<Transition name="slide-right" mode="out-in">
<MdiDotsVertical v-if="!searchActive" class="hover:text-accent outline-0" />
<GeneralIcon v-if="!searchActive" icon="threeDotVertical" class="hover:text-accent outline-0" />
</Transition>
<template #overlay>
@ -438,7 +513,7 @@ const setIcon = async (icon: string, table: TableType) => {
target="_blank"
class="prose-sm hover:(!text-primary !opacity-100) color-transition nc-project-menu-item group after:(!rounded-b)"
>
<MdiOpenInNew class="group-hover:text-accent" />
<GeneralIcon icon="openInNew" class="group-hover:text-accent" />
<!-- Request a data source you need? -->
{{ $t('labels.requestDataSource') }}
</a>
@ -454,12 +529,15 @@ const setIcon = async (icon: string, table: TableType) => {
class="group flex items-center gap-2 pl-2 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(bases[0].id)"
>
<MdiPlus class="w-5" />
<GeneralIcon icon="plus" class="w-5" />
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{ $t('tooltip.addTable') }}</span>
<a-dropdown v-if="!isSharedBase" :trigger="['click']" overlay-class-name="nc-dropdown-import-menu" @click.stop>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0" />
<GeneralIcon
icon="threeDotVertical"
class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0"
/>
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
@ -471,7 +549,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openAirtableImportDialog(bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="group-hover:text-accent" />
<GeneralIcon icon="table" class="group-hover:text-accent" />
Airtable
</div>
</a-menu-item>
@ -482,7 +560,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('csv', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileDocumentOutline class="group-hover:text-accent" />
<GeneralIcon icon="csv" class="group-hover:text-accent" />
CSV file
</div>
</a-menu-item>
@ -493,7 +571,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('json', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiCodeJson class="group-hover:text-accent" />
<GeneralIcon icon="code" class="group-hover:text-accent" />
JSON file
</div>
</a-menu-item>
@ -504,7 +582,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('excel', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="group-hover:text-accent" />
<GeneralIcon icon="excel" class="group-hover:text-accent" />
Microsoft Excel
</div>
</a-menu-item>
@ -558,7 +636,7 @@ const setIcon = async (icon: string, table: TableType) => {
target="_blank"
class="prose-sm hover:(!text-primary !opacity-100) color-transition nc-project-menu-item group after:(!rounded-b)"
>
<MdiOpenInNew class="group-hover:text-accent" />
<GeneralIcon icon="openInNew" class="group-hover:text-accent" />
<!-- Request a data source you need? -->
{{ $t('labels.requestDataSource') }}
</a>
@ -623,7 +701,11 @@ const setIcon = async (icon: string, table: TableType) => {
</component>
</div>
<template v-if="isUIAllowed('tableIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="setIcon($event, table)" />
<GeneralEmojiIcons
class="shadow bg-white p-2"
:show-reset="!!table.meta?.icon"
@select-icon="setIcon($event, table)"
/>
</template>
</component>
</div>
@ -639,7 +721,10 @@ const setIcon = async (icon: string, table: TableType) => {
:trigger="['click']"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 outline-0" />
<GeneralIcon
icon="threeDotVertical"
class="transition-opacity opacity-0 group-hover:opacity-100 outline-0"
/>
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
@ -706,7 +791,7 @@ const setIcon = async (icon: string, table: TableType) => {
class="group flex items-center gap-2 pl-8 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(bases[0].id)"
>
<MdiPlus />
<component :is="iconMap.plus" />
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{
$t('tooltip.addTable')
@ -718,7 +803,10 @@ const setIcon = async (icon: string, table: TableType) => {
overlay-class-name="nc-dropdown-import-menu"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0" />
<component
:is="iconMap.threeDotVertical"
class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0"
/>
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
@ -730,7 +818,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openAirtableImportDialog(bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="group-hover:text-accent" />
<component :is="iconMap.airtable" class="group-hover:text-accent" />
Airtable
</div>
</a-menu-item>
@ -741,7 +829,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('csv', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileDocumentOutline class="group-hover:text-accent" />
<component :is="iconMap.csv" class="group-hover:text-accent" />
CSV file
</div>
</a-menu-item>
@ -752,7 +840,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('json', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiCodeJson class="group-hover:text-accent" />
<component :is="iconMap.json" class="group-hover:text-accent" />
JSON file
</div>
</a-menu-item>
@ -763,7 +851,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('excel', bases[0].id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="group-hover:text-accent" />
<component :is="iconMap.excel" class="group-hover:text-accent" />
Microsoft Excel
</div>
</a-menu-item>
@ -778,7 +866,7 @@ const setIcon = async (icon: string, table: TableType) => {
target="_blank"
class="prose-sm hover:(!text-primary !opacity-100) color-transition nc-project-menu-item group after:(!rounded-b)"
>
<MdiOpenInNew class="group-hover:text-accent" />
<component :is="iconMap.share" class="group-hover:text-accent" />
<!-- Request a data source you need? -->
{{ $t('labels.requestDataSource') }}
</a>
@ -792,7 +880,7 @@ const setIcon = async (icon: string, table: TableType) => {
class="group flex items-center gap-2 pl-8 pr-3 py-2 text-primary/70 hover:(text-primary/100) cursor-pointer select-none"
@click="openTableCreateDialog(base.id)"
>
<MdiPlus />
<component :is="iconMap.plus" />
<span class="text-gray-500 group-hover:(text-primary/100) flex-1 nc-add-new-table">{{
$t('tooltip.addTable')
@ -804,7 +892,10 @@ const setIcon = async (icon: string, table: TableType) => {
overlay-class-name="nc-dropdown-import-menu"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0" />
<component
:is="iconMap.threeDotVertical"
class="transition-opacity opacity-0 group-hover:opacity-100 nc-import-menu outline-0"
/>
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
@ -816,7 +907,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openAirtableImportDialog(base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiTableLarge class="group-hover:text-accent" />
<component :is="iconMap.airtable" class="group-hover:text-accent" />
Airtable
</div>
</a-menu-item>
@ -827,7 +918,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('csv', base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileDocumentOutline class="group-hover:text-accent" />
<component :is="iconMap.csv" class="group-hover:text-accent" />
CSV file
</div>
</a-menu-item>
@ -838,7 +929,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('json', base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiCodeJson class="group-hover:text-accent" />
<component :is="iconMap.json" class="group-hover:text-accent" />
JSON file
</div>
</a-menu-item>
@ -849,7 +940,7 @@ const setIcon = async (icon: string, table: TableType) => {
@click="openQuickImportDialog('excel', base.id)"
>
<div class="color-transition nc-project-menu-item group">
<MdiFileExcel class="group-hover:text-accent" />
<component :is="iconMap.excel" class="group-hover:text-accent" />
Microsoft Excel
</div>
</a-menu-item>
@ -864,7 +955,7 @@ const setIcon = async (icon: string, table: TableType) => {
target="_blank"
class="prose-sm hover:(!text-primary !opacity-100) color-transition nc-project-menu-item group after:(!rounded-b)"
>
<MdiOpenInNew class="group-hover:text-accent" />
<component :is="iconMap.openInNew" class="group-hover:text-accent" />
<!-- Request a data source you need? -->
{{ $t('labels.requestDataSource') }}
</a>
@ -924,7 +1015,11 @@ const setIcon = async (icon: string, table: TableType) => {
</component>
</div>
<template v-if="isUIAllowed('tableIconCustomisation')" #overlay>
<GeneralEmojiIcons class="shadow bg-white p-2" @select-icon="setIcon($event, table)" />
<GeneralEmojiIcons
class="shadow bg-white p-2"
:show-reset="!!table.meta?.icon"
@select-icon="setIcon($event, table)"
/>
</template>
</component>
</div>
@ -938,7 +1033,10 @@ const setIcon = async (icon: string, table: TableType) => {
:trigger="['click']"
@click.stop
>
<MdiDotsVertical class="transition-opacity opacity-0 group-hover:opacity-100 outline-0" />
<component
:is="iconMap.threeDotVertical"
class="transition-opacity opacity-0 group-hover:opacity-100 outline-0"
/>
<template #overlay>
<a-menu class="!py-0 rounded text-sm">
@ -1008,15 +1106,17 @@ const setIcon = async (icon: string, table: TableType) => {
<a-divider class="!my-0" />
<div class="flex items-start flex-col justify-start px-2 py-3 gap-2">
<LazyGeneralAddBaseButton
class="color-transition py-1.5 px-2 text-primary font-bold cursor-pointer select-none hover:text-accent"
/>
<LazyGeneralAddBaseButton class="color-transition py-1.5 px-2 cursor-pointer select-none hover:text-primary" />
<LazyGeneralHelpAndSupport class="color-transition px-2 text-gray-500 cursor-pointer select-none hover:text-accent" />
<GeneralJoinCloud class="color-transition px-2 text-gray-500 cursor-pointer select-none hover:text-accent" />
<GeneralJoinCloud
v-if="!isMobileMode"
class="color-transition px-2 text-gray-500 cursor-pointer select-none hover:text-accent"
/>
<GithubButton
v-if="!isMobileMode"
class="ml-2 py-1"
href="https://github.com/nocodb/nocodb"
data-icon="octicon-star"
@ -1032,6 +1132,7 @@ const setIcon = async (icon: string, table: TableType) => {
<style scoped lang="scss">
.nc-treeview-container {
@apply h-[calc(100vh_-_var(--header-height))];
border-right: 1px solid var(--navbar-border) !important;
}
.nc-treeview-footer-item {

8
packages/nc-gui/components/dashboard/settings/AppStore.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { extractSdkResponseErrorMsg, message, onMounted, useI18n, useNuxtApp } from '#imports'
import { extractSdkResponseErrorMsg, iconMap, message, onMounted, useI18n, useNuxtApp } from '#imports'
const { t } = useI18n()
@ -20,7 +20,7 @@ const fetchPluginApps = async () => {
apps = plugins.map((p) => ({
...p,
tags: p.tags ? p.tags.split(',') : [],
parsedInput: p.input && JSON.parse(p.input),
parsedInput: p.input && JSON.parse(p.input as string),
}))
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -125,14 +125,14 @@ onMounted(async () => {
<a-button v-if="app.parsedInput" size="small" outlined @click="showResetPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-reset">
<MdiCloseCircleOutline />
<component :is="iconMap.closeCircle" />
<div class="flex ml-0.5">Reset</div>
</div>
</a-button>
<a-button v-else size="small" type="primary" @click="showInstallPluginModal(app)">
<div class="flex flex-row justify-center items-center caption capitalize nc-app-store-card-install">
<MdiPlus />
<component :is="iconMap.plus" />
Install
</div>
</a-button>

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

@ -1,11 +1,11 @@
<script setup lang="ts">
import { Tooltip as ATooltip, Empty } from 'ant-design-vue'
import type { AuditType } from 'nocodb-sdk'
import { h, onMounted, timeAgo, useGlobal, useI18n, useNuxtApp, useProject } from '#imports'
import { h, iconMap, onMounted, storeToRefs, timeAgo, useGlobal, useI18n, useNuxtApp, useProject } from '#imports'
const { $api } = useNuxtApp()
const { project } = useProject()
const { project } = storeToRefs(useProject())
const { t } = useI18n()
@ -28,8 +28,8 @@ async function loadAudits(page = currentPage, limit = currentLimit) {
isLoading = true
const { list, pageInfo } = await $api.project.auditList(project.value?.id, {
offset: (limit * (page - 1)).toString(),
limit: limit.toString(),
offset: limit * (page - 1),
limit,
})
audits = list
@ -94,7 +94,7 @@ const columns = [
<a-button class="self-start" @click="loadAudits">
<!-- Reload -->
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
<component :is="iconMap.reload" :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
{{ $t('general.reload') }}
</div>

44
packages/nc-gui/components/dashboard/settings/DataSources.vue

@ -7,7 +7,7 @@ import Metadata from './Metadata.vue'
import UIAcl from './UIAcl.vue'
import Erd from './Erd.vue'
import { ClientType, DataSourcesSubTab } from '~/lib'
import { useNuxtApp, useProject } from '#imports'
import { storeToRefs, useNuxtApp, useProject } from '#imports'
interface Props {
state: string
@ -19,16 +19,24 @@ const props = defineProps<Props>()
const emits = defineEmits(['update:state', 'update:reload', 'awaken'])
const vState = useVModel(props, 'state', emits)
const vReload = useVModel(props, 'reload', emits)
const { $api, $e } = useNuxtApp()
const { project, loadProject } = useProject()
const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
let sources = $ref<BaseType[]>([])
let activeBaseId = $ref('')
let metadiffbases = $ref<string[]>([])
let clientType = $ref<ClientType>(ClientType.MYSQL)
let isReloading = $ref(false)
let forceAwakened = $ref(false)
async function loadBases() {
@ -38,8 +46,8 @@ async function loadBases() {
isReloading = true
vReload.value = true
const baseList = await $api.base.list(project.value?.id)
if (baseList.bases.list && baseList.bases.list.length) {
sources = baseList.bases.list
if (baseList.list && baseList.list.length) {
sources = baseList.list
}
} catch (e) {
console.error(e)
@ -245,11 +253,11 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<a-tooltip v-if="metadiffbases.includes(sources[0].id as string)">
<a-tooltip v-if="metadiffbases.includes(sources[0].id)">
<template #title>Out of sync</template>
<MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" />
<GeneralIcon icon="warning" class="group-hover:text-accent text-primary" />
</a-tooltip>
<MdiDatabaseSync v-else class="text-lg group-hover:text-accent" />
<GeneralIcon v-else icon="sync" class="group-hover:text-accent" />
Sync Metadata
</div>
</a-button>
@ -258,7 +266,7 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.UIAcl)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiDatabaseLockOutline class="text-lg group-hover:text-accent" />
<GeneralIcon icon="acl" class="group-hover:text-accent" />
UI ACL
</div>
</a-button>
@ -267,7 +275,7 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.ERD)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiGraphOutline class="text-lg group-hover:text-accent" />
<GeneralIcon icon="erd" class="group-hover:text-accent" />
ERD
</div>
</a-button>
@ -277,7 +285,7 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.Edit)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiEditOutline class="text-lg group-hover:text-accent" />
<GeneralIcon icon="edit" class="group-hover:text-accent" />
Edit
</div>
</a-button>
@ -300,7 +308,7 @@ watch(
<template #item="{ element: base, index }">
<div v-if="index !== 0" class="ds-table-row border-gray-200">
<div class="ds-table-col ds-table-name">
<MdiDragVertical v-if="sources.length > 2" small class="ds-table-handle" />
<GeneralIcon v-if="sources.length > 2" icon="dragVertical" small class="ds-table-handle" />
<div class="flex items-center gap-1">
<GeneralBaseLogo :base-type="base.type" />
{{ base.is_meta ? 'BASE' : base.alias }} <span class="text-gray-400 text-xs">({{ base.type }})</span>
@ -314,11 +322,11 @@ watch(
@click="baseAction(base.id, DataSourcesSubTab.Metadata)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<a-tooltip v-if="metadiffbases.includes(base.id as string)">
<a-tooltip v-if="metadiffbases.includes(base.id)">
<template #title>Out of sync</template>
<MdiDatabaseAlert class="text-lg group-hover:text-accent text-primary" />
<GeneralIcon icon="warning" class="group-hover:text-accent text-primary" />
</a-tooltip>
<MdiDatabaseSync v-else class="text-lg group-hover:text-accent" />
<GeneralIcon v-else icon="sync" class="group-hover:text-accent" />
Sync Metadata
</div>
</a-button>
@ -327,13 +335,13 @@ watch(
@click="baseAction(base.id, DataSourcesSubTab.UIAcl)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiDatabaseLockOutline class="text-lg group-hover:text-accent" />
<GeneralIcon icon="acl" class="group-hover:text-accent" />
UI ACL
</div>
</a-button>
<a-button class="nc-action-btn cursor-pointer outline-0" @click="baseAction(base.id, DataSourcesSubTab.ERD)">
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiGraphOutline class="text-lg group-hover:text-accent" />
<GeneralIcon icon="erd" class="group-hover:text-accent" />
ERD
</div>
</a-button>
@ -343,13 +351,13 @@ watch(
@click="baseAction(base.id, DataSourcesSubTab.Edit)"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiEditOutline class="text-lg group-hover:text-accent" />
<GeneralIcon icon="edit" class="group-hover:text-accent" />
Edit
</div>
</a-button>
<a-button v-if="!base.is_meta" class="nc-action-btn cursor-pointer outline-0" @click="deleteBase(base)">
<div class="flex items-center gap-2 text-red-500 font-light">
<MdiDeleteOutline class="text-lg group-hover:text-accent" />
<GeneralIcon icon="delete" class="group-hover:text-accent" />
Delete
</div>
</a-button>

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Empty, extractSdkResponseErrorMsg, h, message, useI18n, useNuxtApp, useProject } from '#imports'
import { Empty, extractSdkResponseErrorMsg, h, iconMap, message, storeToRefs, useI18n, useNuxtApp, useProject } from '#imports'
const props = defineProps<{
baseId: string
@ -9,7 +9,9 @@ const emit = defineEmits(['baseSynced'])
const { $api } = useNuxtApp()
const { project, loadTables } = useProject()
const projectStore = useProject()
const { loadTables } = projectStore
const { project } = storeToRefs(projectStore)
const { t } = useI18n()
@ -90,7 +92,7 @@ const columns = [
<!-- Reload -->
<a-button v-e="['a:proj-meta:meta-data:reload']" class="self-start nc-btn-metasync-reload" @click="loadMetaDiff">
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
<component :is="iconMap.reload" :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
{{ $t('general.reload') }}
</div>
</a-button>
@ -133,7 +135,7 @@ const columns = [
<div v-if="isDifferent">
<a-button v-e="['a:proj-meta:meta-data:sync']" class="nc-btn-metasync-sync-now" type="primary" @click="syncMetaDiff">
<div class="flex items-center gap-2">
<MdiDatabaseSync />
<component :is="iconMap.databaseSync" />
{{ $t('activity.metaSync') }}
</div>
</a-button>

6
packages/nc-gui/components/dashboard/settings/Misc.vue

@ -1,10 +1,12 @@
<script setup lang="ts">
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface'
import { useGlobal, useProject, watch } from '#imports'
import { storeToRefs, useGlobal, useProject, watch } from '#imports'
const { includeM2M, showNull } = useGlobal()
const { project, updateProject, projectMeta, loadTables, hasEmptyOrNullFilters } = useProject()
const projectStore = useProject()
const { updateProject, loadTables, hasEmptyOrNullFilters } = projectStore
const { project, projectMeta } = storeToRefs(projectStore)
watch(includeM2M, async () => await loadTables())

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

@ -2,11 +2,7 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
import DataSources from './DataSources.vue'
import Misc from './Misc.vue'
import { DataSourcesSubTab, useI18n, useNuxtApp, useUIPermission, useVModel, watch } from '#imports'
import TeamFillIcon from '~icons/ri/team-fill'
import MultipleTableIcon from '~icons/mdi/table-multiple'
import NotebookOutline from '~icons/mdi/notebook-outline'
import FolderCog from '~icons/mdi/folder-cog'
import { DataSourcesSubTab, iconMap, useI18n, useNuxtApp, useUIPermission, useVModel, watch } from '#imports'
interface Props {
modelValue: boolean
@ -54,7 +50,7 @@ const dataSourcesAwakened = ref(false)
const tabsInfo: TabGroup = {
teamAndAuth: {
title: t('title.teamAndAuth'),
icon: TeamFillIcon,
icon: iconMap.users,
subTabs: {
...(isUIAllowed('userMgmtTab')
? {
@ -82,7 +78,7 @@ const tabsInfo: TabGroup = {
dataSources: {
// Data Sources
title: 'Data Sources',
icon: MultipleTableIcon,
icon: iconMap.datasource,
subTabs: {
dataSources: {
title: 'Data Sources',
@ -97,7 +93,7 @@ const tabsInfo: TabGroup = {
audit: {
// Audit
title: t('title.audit'),
icon: NotebookOutline,
icon: iconMap.book,
subTabs: {
audit: {
// Audit
@ -112,7 +108,7 @@ const tabsInfo: TabGroup = {
projectSettings: {
// Project Settings
title: 'Project Settings',
icon: FolderCog,
icon: iconMap.settings,
subTabs: {
misc: {
// Misc
@ -174,7 +170,7 @@ watch(
data-testid="settings-modal-close-button"
@click="vModel = false"
>
<MdiClose class="cursor-pointer nc-modal-close w-4" />
<component :is="iconMap.close" class="cursor-pointer nc-modal-close w-4" />
</a-button>
</div>
@ -231,7 +227,7 @@ watch(
@click="vDataState = DataSourcesSubTab.New"
>
<div v-if="vDataState === ''" class="flex items-center gap-2 text-primary font-light">
<MdiDatabasePlusOutline class="text-lg group-hover:text-accent" />
<component :is="iconMap.plusCircle" class="group-hover:text-accent" />
New
</div>
</a-button>
@ -242,7 +238,7 @@ watch(
@click="dataSourcesReload = true"
>
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': dataSourcesReload }" />
<component :is="iconMap.reload" :class="{ 'animate-infinite animate-spin !text-success': dataSourcesReload }" />
{{ $t('general.reload') }}
</div>
</a-button>

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

@ -4,8 +4,10 @@ import {
computed,
extractSdkResponseErrorMsg,
h,
iconMap,
message,
onMounted,
storeToRefs,
useGlobal,
useI18n,
useNuxtApp,
@ -20,7 +22,7 @@ const { t } = useI18n()
const { $api, $e } = useNuxtApp()
const { project } = useProject()
const { project } = storeToRefs(useProject())
const { includeM2M } = useGlobal()
@ -119,20 +121,20 @@ const columns = [
<div class="flex flex-row items-center w-full mb-4 gap-2">
<a-input v-model:value="searchInput" placeholder="Search models" class="nc-acl-search">
<template #prefix>
<MdiMagnify />
<component :is="iconMap.search" />
</template>
</a-input>
<a-button class="self-start nc-acl-reload" @click="loadTableList">
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiReload :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
<component :is="iconMap.reload" :class="{ 'animate-infinite animate-spin !text-success': isLoading }" />
Reload
</div>
</a-button>
<a-button class="self-start nc-acl-save" @click="saveUIAcl">
<div class="flex items-center gap-2 text-gray-600 font-light">
<MdiContentSave />
<component :is="iconMap.save" />
Save
</div>
</a-button>

41
packages/nc-gui/components/dashboard/settings/app-store/AppInstall.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { PluginType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, message, onMounted, ref, useI18n, useNuxtApp } from '#imports'
import type { PluginTestReqType, PluginType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, iconMap, message, onMounted, ref, useI18n, useNuxtApp } from '#imports'
const { id } = defineProps<{
id: string
@ -64,19 +64,20 @@ const testSettings = async () => {
loadingAction = Action.Test
try {
const res = await $api.plugin.test({
input: pluginFormData,
id: plugin?.id,
category: plugin?.category,
title: plugin?.title,
})
if (res) {
// Successfully tested plugin settings
message.success(t('msg.success.pluginTested'))
} else {
// Invalid credentials
message.info(t('msg.info.invalidCredentials'))
if (plugin) {
const res = await $api.plugin.test({
input: JSON.stringify(pluginFormData),
title: plugin.title,
category: plugin.category,
} as PluginTestReqType)
if (res) {
// Successfully tested plugin settings
message.success(t('msg.success.pluginTested'))
} else {
// Invalid credentials
message.info(t('msg.info.invalidCredentials'))
}
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -106,7 +107,7 @@ const readPluginDetails = async () => {
const res = await $api.plugin.read(id)
const formDetails = JSON.parse(res.input_schema ?? '{}')
const emptyParsedInput = formDetails.array ? [{}] : {}
const parsedInput = res.input ? JSON.parse(res.input) : emptyParsedInput
const parsedInput = typeof res.input === 'string' ? JSON.parse(res.input) : emptyParsedInput
// the type of 'secure' was XcType.SingleLineText in 0.0.1
// and it has been changed to XcType.Checkbox, since 0.0.2
@ -208,7 +209,11 @@ onMounted(async () => {
v-if="itemIndex !== 0 && columnIndex === plugin.formDetails.items.length - 1"
class="absolute flex flex-col justify-start mt-2 -right-6 top-0"
>
<MdiDeleteOutline class="hover:text-red-400 cursor-pointer" @click="deleteFormRow(itemIndex)" />
<component
:is="iconMap.delete"
class="hover:text-red-400 cursor-pointer"
@click="deleteFormRow(itemIndex)"
/>
</div>
</a-form-item>
</td>
@ -219,7 +224,7 @@ onMounted(async () => {
<td :colspan="plugin.formDetails.items.length" class="text-center">
<a-button type="default" class="!bg-gray-100 rounded-md border-none mr-1" @click="addSetting">
<template #icon>
<MdiPlus class="flex mx-auto" />
<component :is="iconMap.plus" class="flex mx-auto" />
</template>
</a-button>
</td>

14
packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue

@ -15,11 +15,13 @@ import {
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
iconMap,
nextTick,
onMounted,
projectTitleValidator,
readFile,
ref,
storeToRefs,
useApi,
useGlobal,
useI18n,
@ -33,7 +35,9 @@ const emit = defineEmits(['baseCreated'])
const { appInfo } = useGlobal()
const { project, loadProject } = useProject()
const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
const useForm = Form.useForm
@ -562,11 +566,15 @@ watch(
<a-input v-model:value="item.value" />
<MdiClose :style="{ 'font-size': '1.5em', 'color': 'red' }" @click="removeParam(index)" />
<component
:is="iconMap.close"
:style="{ 'font-size': '1.5em', 'color': 'red' }"
@click="removeParam(index)"
/>
</div>
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewParam">
<div class="flex items-center justify-center"><MdiPlus /></div>
<div class="flex items-center justify-center"><component :is="iconMap.plus" /></div>
</a-button>
</a-card>
</a-form-item>

16
packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue

@ -15,10 +15,12 @@ import {
fieldRequiredValidator,
getDefaultConnectionConfig,
getTestDatabaseName,
iconMap,
onMounted,
projectTitleValidator,
readFile,
ref,
storeToRefs,
useApi,
useI18n,
useNuxtApp,
@ -31,7 +33,9 @@ const props = defineProps<{
const emit = defineEmits(['baseUpdated'])
const { project, loadProject } = useProject()
const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
const useForm = Form.useForm
@ -534,11 +538,15 @@ onMounted(async () => {
<a-input v-model:value="item.value" />
<MdiClose :style="{ 'font-size': '1.5em', 'color': 'red' }" @click="removeParam(index)" />
<component
:is="iconMap.close"
:style="{ 'font-size': '1.5em', 'color': 'red' }"
@click="removeParam(index)"
/>
</div>
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewParam">
<div class="flex items-center justify-center"><MdiPlus /></div>
<div class="flex items-center justify-center"><component :is="iconMap.plus" /></div>
</a-button>
</a-card>
</a-form-item>
@ -585,7 +593,7 @@ onMounted(async () => {
</div>
</a-form-item>
<div class="w-full flex items-center mt-2 text-[#e65100]">
<MdiWarning class="mr-1" />
<component :is="iconMap.warning" class="mr-1" />
Please make sure database you are trying to connect is valid! This operation can cause schema loss!!
</div>
</a-form>

12
packages/nc-gui/components/dlg/AirtableImport.vue

@ -7,11 +7,13 @@ import {
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
iconMap,
message,
nextTick,
onBeforeUnmount,
onMounted,
ref,
storeToRefs,
useGlobal,
useNuxtApp,
useProject,
@ -31,7 +33,11 @@ const baseURL = appInfo.ncSiteUrl
const { $state } = useNuxtApp()
const { project, loadTables } = useProject()
const projectStore = useProject()
const { loadTables } = projectStore
const { project } = storeToRefs(projectStore)
const showGoToDashboardButton = ref(false)
@ -402,7 +408,7 @@ onBeforeUnmount(() => {
<a-card ref="logRef" :body-style="{ backgroundColor: '#000000', height: '400px', overflow: 'auto' }">
<div v-for="({ msg, status }, i) in progress" :key="i">
<div v-if="status === 'FAILED'" class="flex items-center">
<MdiCloseCircleOutline class="text-red-500" />
<component :is="iconMap.closeCircle" class="text-red-500" />
<span class="text-red-500 ml-2">{{ msg }}</span>
</div>
@ -423,7 +429,7 @@ onBeforeUnmount(() => {
class="flex items-center"
>
<!-- Importing -->
<MdiLoading class="text-green-500 animate-spin" />
<component :is="iconMap.loading" class="text-green-500 animate-spin" />
<span class="text-green-500 ml-2"> {{ $t('labels.importing') }}</span>
</div>
</a-card>

19
packages/nc-gui/components/dlg/KeyboardShortcuts.vue

@ -15,6 +15,9 @@ const dialogShow = computed({
const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'CTRL'
}
const renderAltOrOptlKey = () => {
return isMac() ? '⌥' : 'ALT'
}
const shortcutList = [
{
@ -197,6 +200,22 @@ const shortcutList = [
keys: [renderCmdOrCtrlKey(), 'Enter'],
behaviour: 'Save current expanded form item',
},
{
keys: [renderAltOrOptlKey(), '→'],
behaviour: 'Switch to next row',
},
{
keys: [renderAltOrOptlKey(), '←'],
behaviour: 'Switch to previous row',
},
{
keys: [renderAltOrOptlKey(), 'S'],
behaviour: 'Save current expanded form item',
},
{
keys: [renderAltOrOptlKey(), 'N'],
behaviour: 'Create a new row',
},
],
},
]

21
packages/nc-gui/components/dlg/QuickImport.vue

@ -12,12 +12,14 @@ import {
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
iconMap,
importCsvUrlValidator,
importExcelUrlValidator,
importUrlValidator,
message,
reactive,
ref,
storeToRefs,
useI18n,
useProject,
useVModel,
@ -31,13 +33,13 @@ interface Props {
importDataOnly?: boolean
}
const { importType, importDataOnly = false, ...rest } = defineProps<Props>()
const { importType, importDataOnly = false, baseId, ...rest } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { tables } = useProject()
const { tables } = storeToRefs(useProject())
const activeKey = ref('uploadTab')
@ -61,7 +63,7 @@ const isParsingData = ref(false)
const useForm = Form.useForm
const importState = reactive({
const defaultImportState = {
fileList: [] as importFileList | streamImportFileList,
url: '',
jsonEditor: {},
@ -72,7 +74,8 @@ const importState = reactive({
firstRowAsHeaders: true,
shouldImportData: true,
},
})
}
const importState = reactive(defaultImportState)
const isImportTypeJson = computed(() => importType === 'json')
@ -176,6 +179,8 @@ async function handleImport() {
return message.error(await extractSdkResponseErrorMsg(e))
} finally {
importLoading.value = false
templateEditorModal.value = false
Object.assign(importState, defaultImportState)
}
dialogShow.value = false
}
@ -375,7 +380,7 @@ const beforeUpload = (file: UploadFile) => {
<template #tab>
<!-- Upload -->
<div class="flex items-center gap-2">
<MdiFileUploadOutline />
<component :is="iconMap.fileUpload" />
{{ $t('general.upload') }}
</div>
</template>
@ -394,7 +399,7 @@ const beforeUpload = (file: UploadFile) => {
@change="handleChange"
@reject="rejectDrop"
>
<MdiFilePlusOutline size="large" />
<component :is="iconMap.plusCircle" size="large" />
<!-- Click or drag file to this area to upload -->
<p class="ant-upload-text">{{ $t('msg.info.import.clickOrDrag') }}</p>
@ -409,7 +414,7 @@ const beforeUpload = (file: UploadFile) => {
<a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiCodeJson />
<component :is="iconMap.json" />
JSON Editor
</span>
</template>
@ -422,7 +427,7 @@ const beforeUpload = (file: UploadFile) => {
<a-tab-pane v-else key="urlTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiLinkVariant />
<component :is="iconMap.link" />
URL
</span>
</template>

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

@ -1,5 +1,17 @@
<script setup lang="ts">
import { Form, computed, nextTick, onMounted, ref, useProject, useTable, useTabs, useVModel, validateTableName } from '#imports'
import {
Form,
computed,
iconMap,
nextTick,
onMounted,
ref,
useProject,
useTable,
useTabs,
useVModel,
validateTableName,
} from '#imports'
import { TabType } from '~/lib'
const props = defineProps<{
@ -78,14 +90,19 @@ const systemColumnsCheckboxInfo = SYSTEM_COLUMNS.map((c, index) => ({
disabled: index === 0,
}))
const creating = ref(false)
const _createTable = async () => {
try {
creating.value = true
await validate()
await createTable()
} catch (e: any) {
e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(',')))
if (e.errorFields.length) return
} finally {
creating.value = false
}
await createTable()
}
onMounted(() => {
@ -109,7 +126,9 @@ 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" :loading="creating" @click="_createTable"
>{{ $t('general.submit') }}
</a-button>
</template>
<div class="pl-10 pr-10 pt-5">
@ -136,8 +155,8 @@ onMounted(() => {
<div class="pointer flex flex-row items-center gap-x-1" @click="isAdvanceOptVisible = !isAdvanceOptVisible">
{{ isAdvanceOptVisible ? $t('general.hideAll') : $t('general.showMore') }}
<MdiMinusCircleOutline v-if="isAdvanceOptVisible" class="text-gray-500" />
<MdiPlusCircleOutline v-else class="text-gray-500" />
<component :is="iconMap.minusCircle" v-if="isAdvanceOptVisible" class="text-gray-500" />
<component :is="iconMap.plusCircle" v-else class="text-gray-500" />
</div>
</div>
<div class="nc-table-advanced-options" :class="{ active: isAdvanceOptVisible }">

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

@ -8,11 +8,13 @@ import {
message,
nextTick,
reactive,
storeToRefs,
useI18n,
useMetas,
useNuxtApp,
useProject,
useTabs,
useUndoRedo,
useVModel,
validateTableName,
watchEffect,
@ -38,7 +40,11 @@ const dialogShow = useVModel(props, 'modelValue', emit)
const { updateTab } = useTabs()
const { loadTables, tables, project, isMysql, isMssql, isPg } = useProject()
const projectStore = useProject()
const { loadTables, isMysql, isMssql, isPg } = projectStore
const { tables, project } = storeToRefs(projectStore)
const { addUndo, defineProjectScope } = useUndoRedo()
const inputEl = $ref<ComponentPublicInstance>()
@ -110,7 +116,7 @@ watchEffect(
{ flush: 'post' },
)
const renameTable = async () => {
const renameTable = async (undo = false) => {
if (!tableMeta) return
loading = true
@ -123,6 +129,26 @@ const renameTable = async () => {
dialogShow.value = false
if (!undo) {
addUndo({
redo: {
fn: (t: string) => {
formState.title = t
renameTable(true)
},
args: [formState.title],
},
undo: {
fn: (t: string) => {
formState.title = t
renameTable(true)
},
args: [tableMeta.title],
},
scope: defineProjectScope({ model: tableMeta }),
})
}
await loadTables()
// update metas
@ -158,7 +184,7 @@ const renameTable = async () => {
<template #footer>
<a-button key="back" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="renameTable">{{ $t('general.submit') }}</a-button>
<a-button key="submit" type="primary" :loading="loading" @click="renameTable()">{{ $t('general.submit') }}</a-button>
</template>
<div class="pl-10 pr-10 pt-5">
@ -172,7 +198,7 @@ const renameTable = async () => {
v-model:value="formState.title"
hide-details
:placeholder="$t('msg.info.enterTableName')"
@keydown.enter="renameTable"
@keydown.enter="renameTable()"
/>
</a-form-item>
</a-form>

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

@ -2,7 +2,7 @@
import type { ComponentPublicInstance } from '@vue/runtime-core'
import type { Form as AntForm, SelectProps } from 'ant-design-vue'
import { capitalize } from '@vue/runtime-core'
import type { FormType, GalleryType, GridType, KanbanType, TableType, ViewType } from 'nocodb-sdk'
import type { FormType, GalleryType, GridType, KanbanType, MapType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, ViewTypes } from 'nocodb-sdk'
import {
computed,
@ -25,13 +25,14 @@ interface Props {
title?: string
selectedViewId?: string
groupingFieldColumnId?: string
geoDataFieldColumnId?: string
views: ViewType[]
meta: TableType
}
interface Emits {
(event: 'update:modelValue', value: boolean): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType): void
(event: 'created', value: GridType | KanbanType | GalleryType | FormType | MapType): void
}
interface Form {
@ -40,9 +41,10 @@ interface Form {
copy_from_id: string | null
// for kanban view only
fk_grp_col_id: string | null
fk_geo_data_col_id: string | null
}
const { views = [], meta, selectedViewId, groupingFieldColumnId, ...props } = defineProps<Props>()
const { views = [], meta, selectedViewId, groupingFieldColumnId, geoDataFieldColumnId, ...props } = defineProps<Props>()
const emits = defineEmits<Emits>()
@ -61,9 +63,10 @@ const form = reactive<Form>({
type: props.type,
copy_from_id: null,
fk_grp_col_id: null,
fk_geo_data_col_id: null,
})
const singleSelectFieldOptions = ref<SelectProps['options']>([])
const viewSelectFieldOptions = ref<SelectProps['options']>([])
const viewNameRules = [
// name is required
@ -72,18 +75,15 @@ const viewNameRules = [
{
validator: (_: unknown, v: string) =>
new Promise((resolve, reject) => {
views.every((v1) => ((v1 as GridType | KanbanType | GalleryType).alias || v1.title) !== v)
? resolve(true)
: reject(new Error(`View name should be unique`))
views.every((v1) => v1.title !== v) ? resolve(true) : reject(new Error(`View name should be unique`))
}),
message: 'View name should be unique',
},
]
const groupingFieldColumnRules = [
// name is required
{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` },
]
const groupingFieldColumnRules = [{ required: true, message: `${t('general.groupingField')} ${t('general.required')}` }]
const geoDataFieldColumnRules = [{ required: true, message: `${t('general.geoDataField')} ${t('general.required')}` }]
const typeAlias = computed(
() =>
@ -92,6 +92,7 @@ const typeAlias = computed(
[ViewTypes.GALLERY]: 'gallery',
[ViewTypes.FORM]: 'form',
[ViewTypes.KANBAN]: 'kanban',
[ViewTypes.MAP]: 'map',
}[props.type]),
)
@ -113,7 +114,7 @@ function init() {
// preset the grouping field column
if (props.type === ViewTypes.KANBAN) {
singleSelectFieldOptions.value = meta
viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.SingleSelect)
.map((field) => {
return {
@ -127,7 +128,26 @@ function init() {
form.fk_grp_col_id = groupingFieldColumnId
} else {
// take the first option
form.fk_grp_col_id = singleSelectFieldOptions.value?.[0]?.value as string
form.fk_grp_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
if (props.type === ViewTypes.MAP) {
viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.GeoData)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (geoDataFieldColumnId) {
// take from the one from copy view
form.fk_geo_data_col_id = geoDataFieldColumnId
} else {
// take the first option
form.fk_geo_data_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
@ -150,7 +170,7 @@ async function onSubmit() {
if (!_meta || !_meta.id) return
try {
let data: GridType | KanbanType | GalleryType | FormType | null = null
let data: GridType | KanbanType | GalleryType | FormType | MapType | null = null
switch (form.type) {
case ViewTypes.GRID:
@ -164,6 +184,9 @@ async function onSubmit() {
break
case ViewTypes.KANBAN:
data = await api.dbView.kanbanCreate(_meta.id, form)
break
case ViewTypes.MAP:
data = await api.dbView.mapCreate(_meta.id, form)
}
if (data) {
@ -207,12 +230,27 @@ async function onSubmit() {
<a-select
v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select"
:options="singleSelectFieldOptions"
:options="viewSelectFieldOptions"
:disabled="groupingFieldColumnId"
placeholder="Select a Grouping Field"
not-found-content="No Single Select Field can be found. Please create one first."
/>
</a-form-item>
<a-form-item
v-if="form.type === ViewTypes.MAP"
:label="$t('general.geoDataField')"
name="fk_geo_data_col_id"
:rules="geoDataFieldColumnRules"
>
<a-select
v-model:value="form.fk_geo_data_col_id"
class="w-full"
:options="viewSelectFieldOptions"
:disabled="geoDataFieldColumnId"
placeholder="Select a GeoData Field"
not-found-content="No GeoData Field can be found. Please create one first."
/>
</a-form-item>
</a-form>
<template #footer>

5
packages/nc-gui/components/erd/HistogramPanel.vue

@ -1,17 +1,18 @@
<script lang="ts" setup>
import { Panel, PanelPosition } from '@vue-flow/additional-components'
import { iconMap } from '#imports'
</script>
<template>
<Panel class="text-xs bg-white border-1 rounded border-gray-200 z-50 nc-erd-histogram" :position="PanelPosition.BottomRight">
<div class="flex flex-col divide-y-1">
<div class="flex items-center gap-1 p-2">
<MdiTableLarge class="text-primary" />
<component :is="iconMap.table" class="text-primary" />
<div>{{ $t('objects.table') }}</div>
</div>
<div class="flex items-center gap-1 p-2">
<MdiEyeCircleOutline class="text-primary" />
<component :is="iconMap.eye" class="text-primary" />
<div>{{ $t('objects.sqlVIew') }}</div>
</div>
</div>

8
packages/nc-gui/components/erd/View.vue

@ -2,11 +2,11 @@
import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import type { ERDConfig } from './utils'
import { reactive, ref, useMetas, useProject, watch } from '#imports'
import { reactive, ref, storeToRefs, useMetas, useProject, watch } from '#imports'
const props = defineProps<{ table?: TableType; baseId?: string }>()
const { tables: projectTables } = useProject()
const { tables: projectTables } = storeToRefs(useProject())
const { metas, getMeta } = useMetas()
@ -40,8 +40,8 @@ const populateTables = async () => {
// if table is provided only get the table and its related tables
localTables = projectTables.value.filter(
(t) =>
t.id === props.table.id ||
props.table.columns?.find(
t.id === props.table?.id ||
props.table?.columns?.find(
(column) =>
column.uidt === UITypes.LinkToAnotherRecord &&
(column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id === t.id,

4
packages/nc-gui/components/general/AddBaseButton.vue

@ -1,4 +1,6 @@
<script setup lang="ts">
import { iconMap } from '#imports'
const { isUIAllowed } = useUIPermission()
const { t } = useI18n()
@ -14,7 +16,7 @@ const toggleDialog = inject(ToggleDialogInj, () => {})
>
<div>
<div class="flex items-center space-x-1">
<RiTeamFill class="mr-1 nc-new-base" />
<component :is="iconMap.users" class="mr-1 nc-new-base" />
<div>{{ t('title.teamAndSettings') }}</div>
</div>
</div>

47
packages/nc-gui/components/general/EmojiIcons.vue

@ -3,6 +3,10 @@ import { Icon } from '@iconify/vue'
import InfiniteLoading from 'v3-infinite-loading'
import { emojiIcons } from '#imports'
const props = defineProps<{
showReset?: boolean
}>()
const emit = defineEmits(['selectIcon'])
let search = $ref('')
@ -23,30 +27,39 @@ const load = () => {
}
}
const selectIcon = (icon: string) => {
const selectIcon = (icon?: string) => {
search = ''
emit('selectIcon', `emojione:${icon}`)
emit('selectIcon', icon && `emojione:${icon}`)
}
</script>
<template>
<div class="p-1 w-[280px] h-[280px] flex flex-col gap-1 justify-start nc-emoji" data-testid="nc-emoji-container">
<div @click.stop>
<input
v-model="search"
data-testid="nc-emoji-filter"
class="p-1 text-xs border-1 w-full overflow-y-auto"
placeholder="Search"
@input="toIndex = 60"
/>
<div>
<div class="p-1 w-[280px] h-[280px] flex flex-col gap-1 justify-start nc-emoji" data-testid="nc-emoji-container">
<div @click.stop>
<input
v-model="search"
data-testid="nc-emoji-filter"
class="p-1 text-xs border-1 w-full overflow-y-auto"
placeholder="Search"
@input="toIndex = 60"
/>
</div>
<div class="flex gap-1 flex-wrap w-full flex-shrink overflow-y-auto scrollbar-thin-dull">
<div v-for="icon of filteredIcons" :key="icon" @click="selectIcon(icon)">
<span class="cursor-pointer nc-emoji-item">
<Icon class="text-xl iconify" :icon="`emojione:${icon}`"></Icon>
</span>
</div>
<InfiniteLoading @infinite="load"><span /></InfiniteLoading>
</div>
</div>
<div class="flex gap-1 flex-wrap w-full flex-shrink overflow-y-auto scrollbar-thin-dull">
<div v-for="icon of filteredIcons" :key="icon" @click="selectIcon(icon)">
<span class="cursor-pointer nc-emoji-item">
<Icon class="text-xl iconify" :icon="`emojione:${icon}`"></Icon>
</span>
<div v-if="props.showReset" class="m-1">
<a-divider class="!my-2 w-full" />
<div class="p-1 mt-1 cursor-pointer text-xs inline-block border-gray-200 border-1 rounded" @click="selectIcon()">
<PhXCircleLight class="text-sm" />
Reset Icon
</div>
<InfiniteLoading @infinite="load"><span /></InfiniteLoading>
</div>
</div>
</template>

6
packages/nc-gui/components/general/HelpAndSupport.vue

@ -1,11 +1,11 @@
<script lang="ts" setup>
import { ref, useGlobal, useProject, useRoute } from '#imports'
import { iconMap, ref, storeToRefs, useGlobal, useProject, useRoute } from '#imports'
const showDrawer = ref(false)
const { appInfo } = useGlobal()
const { project } = useProject()
const { project } = storeToRefs(useProject())
const route = useRoute()
@ -19,7 +19,7 @@ const openSwaggerLink = () => {
class="flex items-center space-x-1 w-full cursor-pointer pl-3 py-1.5 hover:(text-primary bg-primary bg-opacity-5)"
@click="showDrawer = true"
>
<MdiCommentTextOutline class="mr-1" />
<component :is="iconMap.apiAndSupport" class="mr-1" />
<!-- APIs & Support -->
<div>{{ $t('title.APIsAndSupport') }}</div>

11
packages/nc-gui/components/general/Icon.vue

@ -0,0 +1,11 @@
<script lang="ts" setup>
import { iconMap } from '#imports'
const props = defineProps<{
icon: keyof typeof iconMap
}>()
</script>
<template>
<component :is="iconMap[props.icon]" />
</template>

6
packages/nc-gui/components/general/JoinCloud.vue

@ -1,3 +1,7 @@
<script lang="ts" setup>
import { iconMap } from '#imports'
</script>
<template>
<a
v-e="['c:navbar:join-cloud']"
@ -5,7 +9,7 @@
href="https://docs.google.com/forms/d/e/1FAIpQLSfKLe8Rcrq0uo2_jM5W1kbVBbzDiQ3IvlP8Iov61FTekVAvzA/viewform?usp=pp_url"
target="_blank"
>
<PhCloudLightningDuotone class="mr-1" />
<component :is="iconMap.cloud" class="mr-1" />
Join NocoDB Cloud
</a>
</template>

20
packages/nc-gui/components/general/MiniSidebar.vue

@ -1,18 +1,18 @@
<script lang="ts" setup>
import { computed, navigateTo, useGlobal, useProject, useRoute, useSidebar } from '#imports'
import { computed, iconMap, navigateTo, storeToRefs, useGlobal, useProject, useRoute, useSidebar } from '#imports'
const { signOut, signedIn, user, currentVersion } = useGlobal()
const { isOpen } = useSidebar('nc-mini-sidebar', { isOpen: true })
const { project } = useProject()
const { project } = storeToRefs(useProject())
const route = useRoute()
const email = computed(() => user.value?.email ?? '---')
const logout = () => {
signOut()
const logout = async () => {
await signOut()
navigateTo('/signin')
}
</script>
@ -42,7 +42,7 @@ const logout = () => {
<a-menu-item-group title="User Settings">
<a-menu-item key="email" class="!rounded-t">
<nuxt-link v-e="['c:navbar:user:email']" class="group flex items-center no-underline py-2" to="/user">
<MdiAt class="mt-1 group-hover:text-success" />
<component :is="iconMap.at" class="mt-1 group-hover:text-success" />
&nbsp;
<span class="prose group-hover:text-black nc-user-menu-email">{{ email }}</span>
</nuxt-link>
@ -52,7 +52,7 @@ const logout = () => {
<a-menu-item key="signout" class="!rounded-b">
<div v-e="['a:navbar:user:sign-out']" class="group flex items-center py-2" @click="logout">
<MdiLogout class="group-hover:(!text-red-500)" />&nbsp;
<component :is="iconMap.signout" class="group-hover:(!text-red-500)" />&nbsp;
<span class="prose font-semibold text-gray-500 group-hover:text-black nc-user-menu-signout">
{{ $t('general.signOut') }}
</span>
@ -66,7 +66,7 @@ const logout = () => {
<div id="sidebar" ref="sidebar" class="text-white flex-auto flex flex-col items-center w-full">
<a-dropdown :trigger="['contextmenu']" placement="right" overlay-class-name="nc-dropdown">
<div :class="[route.name === 'index' ? 'active' : '']" class="nc-mini-sidebar-item" @click="navigateTo('/')">
<MdiFolder class="cursor-pointer transform hover:scale-105 text-2xl" />
<component :is="iconMap.folder" class="cursor-pointer transform hover:scale-105 text-2xl" />
</div>
<template #overlay>
@ -84,7 +84,7 @@ const logout = () => {
class="group flex items-center gap-2 py-2 hover:text-primary"
@click="navigateTo('/project/create')"
>
<MdiPlus class="text-lg group-hover:text-accent" />
<component :is="iconMap.plus" class="text-lg group-hover:text-accent" />
{{ $t('activity.createProject') }}
</div>
</a-menu-item>
@ -95,7 +95,7 @@ const logout = () => {
class="group flex items-center gap-2 py-2 hover:text-primary"
@click="navigateTo('/project/create-external')"
>
<MdiDatabaseOutline class="text-lg group-hover:text-accent" />
<component :is="iconMap.database" class="text-lg group-hover:text-accent" />
<div v-html="$t('activity.createProjectExtended.extDB')" />
</div>
</a-menu-item>
@ -112,7 +112,7 @@ const logout = () => {
class="nc-mini-sidebar-item"
@click="navigateTo(`/${route.params.projectType}/${route.params.projectId}`)"
>
<MdiDatabase class="cursor-pointer transform hover:scale-105 text-2xl" />
<component :is="iconMap.database" class="cursor-pointer transform hover:scale-105 text-2xl" />
</div>
</a-tooltip>
</div>

19
packages/nc-gui/components/general/PreviewAs.vue

@ -1,10 +1,9 @@
<script lang="ts" setup>
import { onUnmounted, ref, useEventListener, useGlobal, useI18n, useNuxtApp, watch } from '#imports'
import { iconMap, onUnmounted, ref, useEventListener, useGlobal, useI18n, useNuxtApp, watch } from '#imports'
import MdiAccountStar from '~icons/mdi/account-star'
import MdiAccountHardHat from '~icons/mdi/account-hard-hat'
import MdiAccountEdit from '~icons/mdi/account-edit'
import MdiEyeOutline from '~icons/mdi/eye-outline'
import MdiCommentAccountOutline from '~icons/mdi/comment-account-outline'
import PhPencilCircleThin from '~icons/ph/pencil-circle-thin'
import PhChtTeardropTextThin from '~icons/ph/chat-teardrop-text-thin'
import { ProjectRole } from '~/lib'
const { float } = defineProps<{ float?: boolean }>()
@ -24,9 +23,9 @@ const roleList = [
const roleIcon = {
owner: MdiAccountStar,
creator: MdiAccountHardHat,
editor: MdiAccountEdit,
viewer: MdiEyeOutline,
commenter: MdiCommentAccountOutline,
editor: PhPencilCircleThin,
viewer: iconMap.eye,
commenter: PhChtTeardropTextThin,
}
const position = ref({
@ -65,7 +64,7 @@ watch(previewAs, (newRole) => {
class="floating-reset-btn nc-floating-preview-btn p-4"
:style="{ top: position.y, left: position.x }"
>
<MdiDrag class="cursor-move text-white" @mousedown="mouseDown" />
<component :is="iconMap.drag" class="cursor-move text-white" @mousedown="mouseDown" />
<div class="divider" />
@ -83,7 +82,7 @@ watch(previewAs, (newRole) => {
<!-- Close -->
<div class="flex items-center gap-2 cursor-pointer nc-preview-btn-exit-to-app" @click="previewAs = null">
<MdiExitToApp />
<component :is="iconMap.exit" />
{{ $t('general.close') }}
</div>
</div>
@ -105,7 +104,7 @@ watch(previewAs, (newRole) => {
<template v-if="previewAs">
<a-menu-item @click="previewAs = null">
<div class="nc-project-menu-item group">
<MdiClose class="group-hover:text-accent" />
<component :is="iconMap.close" class="group-hover:text-accent" />
<!-- Reset Preview -->
<span class="text-capitalize text-xs whitespace-nowrap">
{{ $t('activity.resetReview') }}

4
packages/nc-gui/components/general/ShareBaseButton.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { isDrawerOrModalExist, isMac, useNuxtApp, useRoute, useUIPermission } from '#imports'
import { iconMap, isDrawerOrModalExist, isMac, useNuxtApp, useRoute, useUIPermission } from '#imports'
const route = useRoute()
@ -45,7 +45,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</template>
<a-button type="primary" class="!rounded-md mr-1" size="medium">
<div class="flex items-center space-x-1 cursor-pointer text-xs font-weight-bold">
<MdiAccountPlusOutline class="mr-1 nc-share-base hover:text-accent text-sm" />
<component :is="iconMap.accountPlus" class="mr-1 nc-share-base hover:text-accent text-sm" />
{{ $t('activity.share') }}
</div>
</a-button>

57
packages/nc-gui/components/general/ShortcutLabel.vue

@ -0,0 +1,57 @@
<script lang="ts" setup>
import { isMac } from '#imports'
const props = defineProps<{
keys: string[]
}>()
const isMacOs = isMac()
const getLabel = (key: string) => {
if (isMacOs) {
switch (key.toLowerCase()) {
case 'alt':
return '⌥'
case 'shift':
return '⇧'
case 'meta':
return '⌘'
case 'control':
case 'ctrl':
return '⌃'
case 'enter':
return '↩'
}
}
switch (key.toLowerCase()) {
case 'arrowup':
return '↑'
case 'arrowdown':
return '↓'
case 'arrowleft':
return '←'
case 'arrowright':
return '→'
}
return key
}
</script>
<template>
<div class="nc-shortcut-label-wrapper">
<div v-for="(key, index) in props.keys" :key="index" class="nc-shortcut-label">
<span>{{ getLabel(key) }}</span>
</div>
</div>
</template>
<style scoped>
.nc-shortcut-label-wrapper {
@apply flex gap-1;
}
.nc-shortcut-label {
@apply text-[0.7rem] leading-6 min-w-5 min-h-5 text-center relative z-0 after:(content-[''] left-0 top-0 -z-1 bg-current opacity-10 absolute w-full h-full rounded) px-1;
}
</style>

26
packages/nc-gui/components/general/Social.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useI18n } from '#imports'
import { iconMap, useI18n } from '#imports'
const { locale } = useI18n()
@ -19,7 +19,12 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
/>
<div v-else class="flex justify-between gap-1 w-full px-2">
<MdiDiscord v-e="['e:community:discord']" class="icon text-[#7289DA]" @click="open('https://discord.gg/5RgZmkW')" />
<component
:is="iconMap.discord"
v-e="['e:community:discord']"
class="icon text-[#7289DA]"
@click="open('https://discord.gg/5RgZmkW')"
/>
<div
v-e="['e:community:discourse']"
@ -29,11 +34,22 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
<div class="discourse" />
</div>
<MdiReddit v-e="['e:community:reddit']" class="icon text-[#FF4600]" @click="open('https://www.reddit.com/r/NocoDB/')" />
<component
:is="iconMap.reddit"
v-e="['e:community:reddit']"
class="icon text-[#FF4600]"
@click="open('https://www.reddit.com/r/NocoDB/')"
/>
<MdiTwitter v-e="['e:community:twitter']" class="icon text-[#1DA1F2]" @click="open('https://twitter.com/NocoDB')" />
<component
:is="iconMap.twitter"
v-e="['e:community:twitter']"
class="icon text-[#1DA1F2]"
@click="open('https://twitter.com/NocoDB')"
/>
<MdiCalendarMonth
<component
:is="iconMap.calendar"
v-e="['e:community:book-demo']"
class="icon text-green-500"
@click="open('https://calendly.com/nocodb-meeting')"

20
packages/nc-gui/components/general/SocialCard.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { enumColor as colors, useDialog, useGlobal, useNuxtApp } from '#imports'
import { enumColor as colors, iconMap, useDialog, useGlobal, useNuxtApp } from '#imports'
const { $e } = useNuxtApp()
@ -39,7 +39,7 @@ function openKeyboardShortcutDialog() {
to="https://docs.nocodb.com/"
>
<div class="ml-3 flex items-center text-sm">
<MdiBookOpenOutline class="text-lg text-accent" />
<component :is="iconMap.book" class="text-lg text-accent" />
<span class="ml-3">{{ $t('labels.documentation') }}</span>
</div>
</nuxt-link>
@ -55,7 +55,7 @@ function openKeyboardShortcutDialog() {
to="https://apis.nocodb.com/"
>
<div class="ml-3 flex items-center text-sm">
<MdiJson class="text-lg text-green-500" />
<component :is="iconMap.json" class="text-lg text-green-500" />
<!-- todo: i18n -->
<span class="ml-3">API {{ $t('labels.documentation') }}</span>
</div>
@ -72,7 +72,7 @@ function openKeyboardShortcutDialog() {
target="_blank"
>
<div class="flex items-center text-sm">
<mdi-github class="mx-3 text-lg" />
<component :is="iconMap.github" class="mx-3 text-lg" />
<div v-if="isRtlLang">
<!-- us on Github -->
{{ $t('labels.community.starUs2') }}
@ -101,7 +101,7 @@ function openKeyboardShortcutDialog() {
target="_blank"
>
<div class="flex items-center text-sm">
<mdi-calendar-month class="mx-3 text-lg" :color="colors.dark[3 % colors.dark.length]" />
<component :is="iconMap.calendar" class="mx-3 text-lg" :color="colors.dark[3 % colors.dark.length]" />
<!-- Book a Free DEMO -->
<div>
{{ $t('labels.community.bookDemo') }}
@ -120,7 +120,7 @@ function openKeyboardShortcutDialog() {
target="_blank"
>
<div class="flex items-center text-sm">
<mdi-discord class="mx-3 text-lg" :color="colors.dark[0 % colors.dark.length]" />
<component :is="iconMap.discord" class="mx-3 text-lg" :color="colors.dark[0 % colors.dark.length]" />
<!-- Get your questions answered -->
<div>
{{ $t('labels.community.getAnswered') }}
@ -139,7 +139,7 @@ function openKeyboardShortcutDialog() {
target="_blank"
>
<div class="flex items-center text-sm">
<mdi-twitter class="mx-3 text-lg" :color="colors.dark[1 % colors.dark.length]" />
<component :is="iconMap.twitter" class="mx-3 text-lg" :color="colors.dark[1 % colors.dark.length]" />
<!-- Follow NocoDB -->
<div>
{{ $t('labels.community.followNocodb') }}
@ -176,15 +176,15 @@ function openKeyboardShortcutDialog() {
to="https://www.reddit.com/r/NocoDB/"
>
<div class="ml-3 flex items-center text-sm">
<LogosRedditIcon />
<component :is="iconMap.reddit" color="red" />
<span class="ml-4">/r/NocoDB/</span>
</div>
</nuxt-link>
</a-list-item>
<a-list-item @click="openKeyboardShortcutDialog">
<div class="ml-3 flex items-center text-sm">
<MdiKeyboard class="text-lg text-primary" />
<div class="ml-3 flex items-center text-sm cursor-pointer">
<component :is="iconMap.keyboard" class="text-lg text-primary" />
<span class="ml-4">{{ $t('title.keyboardShortcut') }}</span>
</div>
</a-list-item>

7
packages/nc-gui/components/general/TableIcon.vue

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { Icon as IcIcon } from '@iconify/vue'
import type { TableType } from 'nocodb-sdk'
import { iconMap } from '#imports'
const { meta: tableMeta } = defineProps<{
meta: TableType
@ -15,8 +16,6 @@ const { meta: tableMeta } = defineProps<{
:icon="tableMeta.meta?.icon"
/>
<MdiEyeCircleOutline v-else-if="tableMeta?.type === 'view'" class="w-5" />
<MdiTableLarge v-else class="w-5" />
<component :is="iconMap.eye" v-else-if="tableMeta?.type === 'view'" class="w-5" />
<component :is="iconMap.table" v-else class="w-5" />
</template>
<style scoped></style>

42
packages/nc-gui/components/shared-view/Map.vue

@ -0,0 +1,42 @@
<script setup lang="ts">
import {
ActiveViewInj,
FieldsInj,
IsPublicInj,
MetaInj,
ReadonlyInj,
ReloadViewDataHookInj,
useProvideMapViewStore,
} from '#imports'
const { sharedView, meta, sorts, nestedFilters } = useSharedView()
const reloadEventHook = createEventHook()
provide(ReloadViewDataHookInj, reloadEventHook)
provide(ReadonlyInj, ref(true))
provide(MetaInj, meta)
provide(ActiveViewInj, sharedView)
provide(FieldsInj, ref(meta.value?.columns || []))
provide(IsPublicInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
useProvideMapViewStore(meta, sharedView, true)
</script>
<template>
<div class="nc-container h-full mt-1.5 px-12">
<div class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar />
<div class="h-full flex-1 min-w-0 min-h-0 bg-gray-50">
<LazySmartsheetMap />
</div>
</div>
</div>
</template>

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

@ -6,6 +6,7 @@ import {
inject,
message,
ref,
storeToRefs,
useCopy,
useGlobal,
useI18n,
@ -24,7 +25,7 @@ const emits = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { project } = $(useProject())
const { project } = $(storeToRefs(useProject()))
const { appInfo, token } = $(useGlobal())
@ -131,7 +132,7 @@ const onCopyToClipboard = async () => {
await copy(code)
// Copied to clipboard
message.info(t('msg.info.copiedToClipboard'))
} catch (e) {
} catch (e: any) {
message.error(e.message)
}
}

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

@ -8,6 +8,7 @@ import {
IsFormInj,
IsLockedInj,
IsPublicInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
@ -21,6 +22,7 @@ import {
isDuration,
isEmail,
isFloat,
isGeoData,
isInt,
isJSON,
isManualSaved,
@ -38,6 +40,7 @@ import {
isYear,
provide,
ref,
storeToRefs,
toRef,
useDebounceFn,
useProject,
@ -64,7 +67,7 @@ const column = toRef(props, 'column')
const active = toRef(props, 'active', false)
const readOnly = toRef(props, 'readOnly', undefined)
const readOnly = toRef(props, 'readOnly', false)
provide(ColumnInj, column)
@ -82,9 +85,11 @@ const isPublic = inject(IsPublicInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const { currentRow } = useSmartsheetRowStoreOrThrow()
const { sqlUis } = useProject()
const { sqlUis } = storeToRefs(useProject())
const sqlUi = ref(column.value?.base_id ? sqlUis.value[column.value?.base_id] : Object.values(sqlUis.value)[0])
@ -100,7 +105,9 @@ const syncValue = useDebounceFn(
)
const vModel = computed({
get: () => props.modelValue,
get: () => {
return props.modelValue
},
set: (val) => {
if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true
@ -114,11 +121,10 @@ const vModel = computed({
},
})
const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
const navigate = (dir: NavigateDir, e: KeyboardEvent) => {
if (isJSON(column.value)) return
if (currentRow.value.rowMeta.changed || currentRow.value.rowMeta.new) {
emit('save')
currentRow.value.rowMeta.changed = false
}
emit('navigate', dir)
@ -136,21 +142,33 @@ const isNumericField = computed(() => {
isDuration(column.value)
)
})
// disable contexxtmenu event propagation when cell is in
// editable state and typable (e.g. text area)
// this is to prevent the custom grid view context menu from opening
const onContextmenu = (e: MouseEvent) => {
if (props.editEnabled && isTypableInputColumn(column.value)) {
e.stopPropagation()
}
}
</script>
<template>
<div
class="nc-cell w-full h-full"
class="nc-cell w-full h-full relative"
:class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ 'text-blue-600': isPrimary(column) && !props.virtual && !isForm },
{ 'nc-grid-numeric-cell': isGrid && !isForm && isNumericField },
{ 'h-[40px]': !props.editEnabled && isForm && !isSurveyForm },
]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
@keydown.enter.exact="navigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
@contextmenu="onContextmenu"
>
<template v-if="column">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellGeoData v-else-if="isGeoData(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" />

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

@ -10,6 +10,7 @@ import {
computed,
createEventHook,
extractSdkResponseErrorMsg,
iconMap,
inject,
message,
onClickOutside,
@ -97,8 +98,12 @@ const submitted = ref(false)
const activeRow = ref('')
const editEnabled = ref<boolean[]>([])
const { t } = useI18n()
const { betaFeatureToggleState } = useBetaFeatureToggle()
const updateView = useDebounceFn(
() => {
if ((formViewData.value?.subheading?.length || 0) > 255) {
@ -281,6 +286,8 @@ function setFormData() {
.sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, required: !!c.required }))
editEnabled.value = new Array(localColumns.value.length).fill(false)
systemFieldsIds.value = getSystemColumns(col).map((c) => c.fk_column_id)
hiddenColumns.value = col.filter(
@ -361,6 +368,10 @@ function handleMouseUp(col: Record<string, any>, hiddenColIndex: number) {
}
}
const columnSupportsScanning = (elementType: UITypes) =>
betaFeatureToggleState.show &&
[UITypes.SingleLineText, UITypes.Number, UITypes.Email, UITypes.URL, UITypes.LongText].includes(elementType)
onClickOutside(draggableRef, () => {
activeRow.value = ''
})
@ -480,7 +491,7 @@ watch(view, (nextView) => {
<a-dropdown v-model:visible="showColumnDropdown" :trigger="['click']" overlay-class-name="nc-dropdown-form-add-column">
<button type="button" class="group w-full mt-2" @click.stop="showColumnDropdown = true">
<span class="flex items-center flex-wrap justify-center gap-2 prose-sm text-gray-400">
<MdiPlus class="color-transition transform group-hover:(text-accent scale-125)" />
<component :is="iconMap.plus" class="color-transition transform group-hover:(text-accent scale-125)" />
<!-- Add new field to this table -->
<span class="color-transition group-hover:text-primary break-words">
{{ $t('activity.addField') }}
@ -586,7 +597,8 @@ watch(view, (nextView) => {
v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)"
class="absolute flex top-2 right-2"
>
<MdiEyeOffOutline
<component
:is="iconMap.eyeSlash"
class="opacity-0 nc-field-remove-icon"
data-testid="nc-field-remove-icon"
@click.stop="hideColumn(index)"
@ -616,6 +628,30 @@ watch(view, (nextView) => {
/>
</div>
<a-form-item v-if="columnSupportsScanning(element.uidt)" class="my-0 w-1/2 !mb-1">
<div class="flex gap-2 items-center">
<span
class="text-gray-500 mr-2 nc-form-input-required"
data-testid="nc-form-input-enable-scanner"
@click="
() => {
element.general.enable_scanner = !element.general.enable_scanner
updateColMeta(element)
}
"
>
{{ $t('general.enableScanner') }}
</span>
<a-switch
v-model:checked="element.enable_scanner"
v-e="['a:form-view:field:mark-enable-scaner']"
size="small"
@change="updateColMeta(element)"
/>
</div>
</a-form-item>
<a-form-item class="my-0 w-1/2 !mb-1">
<a-input
v-model:value="element.label"
@ -697,7 +733,10 @@ watch(view, (nextView) => {
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element"
:edit-enabled="true"
:edit-enabled="editEnabled[index]"
@click="editEnabled[index] = true"
@cancel="editEnabled[index] = false"
@update:edit-enabled="editEnabled[index] = $event"
@click.stop.prevent
/>
</a-form-item>
@ -827,7 +866,7 @@ watch(view, (nextView) => {
@apply px-4 min-h-[75px] w-full h-full;
.nc-attachment {
@apply md:(w-[50px] h-[50px]) lg:(w-[75px] h-[75px]) min-h-[50px] min-w-[50px];
@apply md: (w-[50px] h-[50px]) lg:(w-[75px] h-[75px]) min-h-[50px] min-w-[50px];
}
.nc-attachment-cell-dropzone {

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

@ -17,6 +17,7 @@ import {
computed,
createEventHook,
extractPkFromRow,
iconMap,
inject,
isImage,
isLTAR,
@ -262,7 +263,7 @@ watch(view, async (nextView) => {
</template>
</a-carousel>
<MdiFileImageBox v-else class="w-full h-48 my-4 text-cool-gray-200" />
<component :is="iconMap.imagePlaceholder" v-else class="w-full h-48 my-4 text-cool-gray-200" />
</template>
<div v-for="col in fieldsWithoutCover" :key="`record-${record.row.id}-${col.id}`">

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ColumnReqType, ColumnType, GridType, TableType, ViewType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType, GridType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -23,6 +23,7 @@ import {
createEventHook,
enumColor,
extractPkFromRow,
iconMap,
inject,
isColumnRequiredAndNull,
isDrawerOrModalExist,
@ -43,6 +44,7 @@ import {
useRoute,
useSmartsheetStoreOrThrow,
useUIPermission,
useUndoRedo,
useViewData,
watch,
} from '#imports'
@ -72,6 +74,8 @@ const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
const route = useRoute()
const router = useRouter()
const { addUndo, clone, defineViewScope } = useUndoRedo()
// todo: get from parent ( inject or use prop )
const isView = false
@ -118,6 +122,7 @@ const {
selectedAllRecords,
removeRowIfNew,
navigateToSiblingRow,
getExpandedRowIndex,
} = useViewData(meta, view, xWhere)
const { getMeta } = useMetas()
@ -453,6 +458,61 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col]
if (isVirtualCol(columnObj)) {
addUndo({
undo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
if (paginationData.value.pageSize === pg.pageSize) {
if (paginationData.value.page !== pg.page) {
await changePage(pg.page!)
}
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (
columnObj.title &&
rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) &&
columnObj.id === col.id
) {
rowObj.row[columnObj.title] = row.row[columnObj.title]
await rowRefs[ctx.row]!.addLTARRef(rowObj.row[columnObj.title], columnObj)
await rowRefs[ctx.row]!.syncLTARRefs(rowObj.row)
activeCell.col = ctx.col
activeCell.row = ctx.row
scrollToCell?.()
} else {
throw new Error('Record could not be found')
}
} else {
throw new Error('Page size changed')
}
},
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationData.value)],
},
redo: {
fn: async (ctx: { row: number; col: number }, col: ColumnType, row: Row, pg: PaginatedType) => {
if (paginationData.value.pageSize === pg.pageSize) {
if (paginationData.value.page !== pg.page) {
await changePage(pg.page!)
}
const rowId = extractPkFromRow(row.row, meta.value?.columns as ColumnType[])
const rowObj = data.value[ctx.row]
const columnObj = fields.value[ctx.col]
if (rowId === extractPkFromRow(rowObj.row, meta.value?.columns as ColumnType[]) && columnObj.id === col.id) {
await rowRefs[ctx.row]!.clearLTARCell(columnObj)
activeCell.col = ctx.col
activeCell.row = ctx.row
scrollToCell?.()
} else {
throw new Error('Record could not be found')
}
} else {
throw new Error('Page size changed')
}
},
args: [clone(ctx), clone(columnObj), clone(rowObj), clone(paginationData.value)],
},
scope: defineViewScope({ view: view.value }),
})
await rowRefs[ctx.row]!.clearLTARCell(columnObj)
return
}
@ -761,7 +821,7 @@ const closeAddColumnDropdown = () => {
overlay-class-name="nc-dropdown-grid-add-column"
>
<div class="h-full w-[60px] flex items-center justify-center">
<MdiPlus class="text-sm nc-column-add" />
<component :is="iconMap.plus" class="text-sm nc-column-add" />
</div>
<template #overlay>
@ -787,13 +847,13 @@ const closeAddColumnDropdown = () => {
: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 class="items-center flex gap-1 min-w-[60px]">
<div
v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }"
>
{{ ((paginationData.page ?? 1) - 1) * 25 + rowIndex + 1 }}
{{ ((paginationData.page ?? 1) - 1) * (paginationData.pageSize ?? 25) + rowIndex + 1 }}
</div>
<div
v-if="!readOnly"
@ -828,7 +888,8 @@ const closeAddColumnDropdown = () => {
v-else
class="cursor-pointer flex items-center border-1 active:ring rounded p-1 hover:(bg-primary bg-opacity-10)"
>
<MdiArrowExpand
<component
:is="iconMap.expand"
v-e="['c:row-expand']"
class="select-none transform hover:(text-accent scale-120) nc-row-expand"
@click="expandForm(row, state)"
@ -899,7 +960,7 @@ const closeAddColumnDropdown = () => {
@click="addEmptyRow()"
>
<div class="px-2 w-full flex items-center text-gray-500">
<MdiPlus class="text-pint-500 text-xs ml-2 text-primary" />
<component :is="iconMap.plus" class="text-pint-500 text-xs ml-2 text-primary" />
<span class="ml-1">
{{ $t('activity.addRow') }}
@ -980,6 +1041,8 @@ const closeAddColumnDropdown = () => {
:row-id="routeQuery.rowId"
:view="view"
show-next-prev-icons
:first-row="getExpandedRowIndex() === 0"
:last-row="getExpandedRowIndex() === data.length - 1"
@next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)"
/>
@ -1056,7 +1119,7 @@ const closeAddColumnDropdown = () => {
position: sticky !important;
left: 80px;
z-index: 5;
@apply border-r-1 border-r-gray-300;
@apply border-r-2 border-r-gray-300;
}
tbody td:nth-child(2) {
@ -1064,7 +1127,7 @@ const closeAddColumnDropdown = () => {
left: 80px;
z-index: 4;
background: white;
@apply shadow-lg border-r-1 border-r-gray-300;
@apply border-r-2 border-r-gray-300;
}
}

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

@ -12,6 +12,8 @@ import {
IsPublicInj,
MetaInj,
OpenNewRecordFormHookInj,
extractPkFromRow,
iconMap,
inject,
isImage,
isLTAR,
@ -20,6 +22,7 @@ import {
provide,
useAttachment,
useKanbanViewStoreOrThrow,
useUndoRedo,
} from '#imports'
import type { Row as RowType } from '~/lib'
@ -75,12 +78,15 @@ const {
deleteStack,
shouldScrollToRight,
deleteRow,
moveHistory,
} = useKanbanViewStoreOrThrow()
const { isUIAllowed } = useUIPermission()
const { appInfo } = $(useGlobal())
const { addUndo, defineViewScope } = useUndoRedo()
provide(IsFormInj, ref(false))
provide(IsGalleryInj, ref(false))
@ -209,7 +215,7 @@ function onMoveCallback(event: { draggedContext: { futureIndex: number } }) {
}
}
async function onMoveStack(event: any) {
async function onMoveStack(event: any, undo = false) {
if (event.moved) {
const { oldIndex, newIndex } = event.moved
const { fk_grp_col_id, meta: stack_meta } = kanbanMetaData.value
@ -220,17 +226,52 @@ async function onMoveStack(event: any) {
await updateKanbanMeta({
meta: stackMetaObj,
})
if (!undo) {
addUndo({
undo: {
fn: async (e: any) => {
const temp = groupingFieldColOptions.value.splice(e.moved.newIndex, 1)
groupingFieldColOptions.value.splice(e.moved.oldIndex, 0, temp[0])
await onMoveStack(e, true)
},
args: [{ moved: { oldIndex, newIndex } }],
},
redo: {
fn: async (e: any) => {
const temp = groupingFieldColOptions.value.splice(e.moved.oldIndex, 1)
groupingFieldColOptions.value.splice(e.moved.newIndex, 0, temp[0])
await onMoveStack(e, true)
},
args: [{ moved: { oldIndex, newIndex } }, true],
},
scope: defineViewScope({ view: view.value }),
})
}
}
}
async function onMove(event: any, stackKey: string) {
if (event.added) {
const ele = event.added.element
moveHistory.value.unshift({
op: 'added',
pk: extractPkFromRow(event.added.element.row, meta.value!.columns!),
index: event.added.newIndex,
stack: stackKey,
})
ele.row[groupingField.value] = stackKey
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1)
await updateOrSaveRow(ele)
} else if (event.removed) {
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! - 1)
moveHistory.value.unshift({
op: 'removed',
pk: extractPkFromRow(event.removed.element.row, meta.value!.columns!),
index: event.removed.oldIndex,
stack: stackKey,
})
}
}
@ -272,7 +313,7 @@ const openNewRecordFormHookHandler = async () => {
const newRow = await addEmptyRow()
// preset the grouping field value
newRow.row = {
[groupingField.value]: selectedStackTitle.value,
[groupingField.value]: selectedStackTitle.value === '' ? null : selectedStackTitle.value,
}
// increase total count by 1
countByStack.value.set(null, countByStack.value.get(null)! + 1)
@ -366,7 +407,7 @@ watch(view, async (nextView) => {
>
<LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText>
<span v-if="!isLocked" class="w-full flex w-[15px]">
<mdi-menu-down class="text-grey text-lg ml-auto" />
<component :is="iconMap.arrowDown" class="text-grey text-lg ml-auto" />
</span>
</div>
<template v-if="!isLocked" #overlay>
@ -382,13 +423,13 @@ watch(view, async (nextView) => {
"
>
<div class="py-2 flex gap-2 items-center">
<mdi-plus class="text-gray-500" />
<component :is="iconMap.plus" class="text-gray-500" />
{{ $t('activity.addNewRecord') }}
</div>
</a-menu-item>
<a-menu-item v-e="['c:kanban:collapse-stack']" @click="handleCollapseStack(stackIdx)">
<div class="py-2 flex gap-2 items-center">
<mdi-arrow-collapse class="text-gray-500" />
<component :is="iconMap.arrowCollapse" class="text-gray-500" />
{{ $t('activity.kanban.collapseStack') }}
</div>
</a-menu-item>
@ -398,7 +439,7 @@ watch(view, async (nextView) => {
@click="handleDeleteStackClick(stack.title, stackIdx)"
>
<div class="py-2 flex gap-2 items-center">
<mdi-delete class="text-gray-500" />
<component :is="iconMap.delete" class="text-gray-500" />
{{ $t('activity.kanban.deleteStack') }}
</div>
</a-menu-item>
@ -470,7 +511,7 @@ watch(view, async (nextView) => {
</template>
</a-carousel>
<MdiFileImageBox v-else class="w-full h-48 my-4 text-cool-gray-200" />
<component :is="iconMap.imagePlaceholder" v-else class="w-full h-48 my-4 text-cool-gray-200" />
</template>
<div
v-for="col in fieldsWithoutCover"
@ -524,7 +565,8 @@ watch(view, async (nextView) => {
<a-layout-footer>
<div v-if="formattedData.get(stack.title) && countByStack.get(stack.title) >= 0" class="mt-5 text-center">
<!-- Stack Title -->
<mdi-plus
<component
:is="iconMap.plus"
v-if="!isPublic && !isLocked"
class="text-pint-500 text-lg text-primary cursor-pointer"
@click="
@ -570,7 +612,7 @@ watch(view, async (nextView) => {
:class="{ capitalize: stack.title === null }"
>
<LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText>
<mdi-menu-down class="text-grey text-lg" />
<component :is="iconMap.arrowDown" class="text-grey text-lg" />
</div>
<!-- Record Count -->
{{ formattedData.get(stack.title).length }} / {{ countByStack.get(stack.title) }}
@ -586,7 +628,7 @@ watch(view, async (nextView) => {
<a-menu class="shadow !rounded !py-0" @click="contextMenu = false">
<a-menu-item v-if="contextMenuTarget" @click="expandForm(contextMenuTarget)">
<div v-e="['a:kanban:expand-record']" class="nc-project-menu-item nc-kanban-context-menu-item">
<MdiArrowExpand class="flex" />
<component :is="iconMap.expand" class="flex" />
<!-- Expand Record -->
{{ $t('activity.expandRecord') }}
</div>
@ -594,7 +636,7 @@ watch(view, async (nextView) => {
<a-divider class="!m-0 !p-0" />
<a-menu-item v-if="contextMenuTarget" @click="deleteRow(contextMenuTarget)">
<div v-e="['a:kanban:delete-record']" class="nc-project-menu-item nc-kanban-context-menu-item">
<MdiDeleteOutline class="flex" />
<component :is="iconMap.delete" class="flex" />
<!-- Delete Record -->
{{ $t('activity.deleteRecord') }}
</div>

285
packages/nc-gui/components/smartsheet/Map.vue

@ -0,0 +1,285 @@
<script lang="ts" setup>
import 'leaflet/dist/leaflet.css'
import L, { LatLng } from 'leaflet'
import 'leaflet.markercluster'
import { ViewTypes } from 'nocodb-sdk'
import { IsPublicInj, OpenNewRecordFormHookInj, iconMap, latLongToJoinedString, onMounted, provide, ref } from '#imports'
import type { Row } from '~/lib'
const route = useRoute()
const popupIsOpen = ref(false)
const popUpRow = ref<Row>()
const fields = inject(FieldsInj, ref([]))
const router = useRouter()
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const reloadViewMetaHook = inject(ReloadViewMetaHookInj)
const { formattedData, loadMapData, loadMapMeta, mapMetaData, geoDataFieldColumn, addEmptyRow, paginationData } =
useMapViewStoreOrThrow()
const markersClusterGroupRef = ref<L.MarkerClusterGroup>()
const mapContainerRef = ref<HTMLElement>()
const myMapRef = ref<L.Map>()
const isPublic = inject(IsPublicInj, ref(false))
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>()
const fallBackCenterLocation = {
lat: 51,
lng: 0.0,
}
const getMapZoomLocalStorageKey = (viewId: string) => {
return `mapView.${viewId}.zoom`
}
const getMapCenterLocalStorageKey = (viewId: string) => `mapView.${viewId}.center`
const expandForm = (row: Row, state?: Record<string, any>) => {
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
router.push({
query: {
...route.query,
rowId,
},
})
} else {
expandedFormRow.value = row
expandedFormRowState.value = state
expandedFormDlg.value = true
}
}
openNewRecordFormHook?.on(async () => {
const newRow = await addEmptyRow()
expandForm(newRow)
})
const expandedFormOnRowIdDlg = computed({
get() {
return !!route.query.rowId
},
set(val) {
if (!val)
router.push({
query: {
...route.query,
rowId: undefined,
},
})
},
})
const addMarker = (lat: number, long: number, row: Row) => {
if (markersClusterGroupRef.value == null) {
throw new Error('Marker cluster is null')
}
const newMarker = L.marker([lat, long], {
alt: `${lat}, ${long}`,
}).on('click', () => {
if (newMarker && isPublic.value) {
popUpRow.value = row
popupIsOpen.value = true
} else {
expandForm(row)
}
})
markersClusterGroupRef.value?.addLayer(newMarker)
}
const resetZoomAndCenterBasedOnLocalStorage = () => {
if (mapMetaData?.value?.fk_view_id == null) {
return
}
const initialZoomLevel = parseInt(localStorage.getItem(getMapZoomLocalStorageKey(mapMetaData.value.fk_view_id)) || '10')
const initialCenterLocalStorageStr = localStorage.getItem(getMapCenterLocalStorageKey(mapMetaData.value.fk_view_id))
const initialCenter = initialCenterLocalStorageStr ? JSON.parse(initialCenterLocalStorageStr) : fallBackCenterLocation
myMapRef?.value?.setView([initialCenter.lat, initialCenter.lng], initialZoomLevel)
}
onBeforeMount(async () => {
await loadMapMeta()
await loadMapData()
})
onMounted(async () => {
const myMap = L.map(mapContainerRef.value!, {
center: new LatLng(10, 10),
zoom: 2,
})
myMapRef.value = myMap
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(myMap)
markersClusterGroupRef.value = L.markerClusterGroup({
iconCreateFunction(cluster: { getChildCount: () => number }) {
return L.divIcon({
html: `${cluster.getChildCount()}`,
className: 'bg-pink rounded-full flex items-center justify-center geo-map-marker-cluster',
iconSize: new L.Point(40, 40),
})
},
})
myMap.addLayer(markersClusterGroupRef.value)
myMap.on('zoomend', function () {
if (localStorage != null && mapMetaData?.value?.fk_view_id) {
localStorage.setItem(getMapZoomLocalStorageKey(mapMetaData.value.fk_view_id), myMap.getZoom().toString())
}
})
myMap.on('moveend', function () {
if (localStorage != null && mapMetaData?.value?.fk_view_id) {
localStorage.setItem(getMapCenterLocalStorageKey(mapMetaData?.value?.fk_view_id), JSON.stringify(myMap.getCenter()))
}
})
myMap.on('contextmenu', async function (e) {
const { lat, lng } = e.latlng
const newRow = await addEmptyRow()
if (geoDataFieldColumn.value?.title) {
newRow.row[geoDataFieldColumn.value.title] = latLongToJoinedString(lat, lng)
}
expandForm(newRow)
})
})
reloadViewMetaHook?.on(async () => {
await loadMapMeta()
})
reloadViewDataHook?.on(async () => {
await loadMapData()
})
provide(ReloadRowDataHookInj, reloadViewDataHook)
watch([formattedData, mapMetaData, markersClusterGroupRef], () => {
if (formattedData.value == null || mapMetaData.value?.fk_view_id == null || markersClusterGroupRef.value == null) {
return
}
resetZoomAndCenterBasedOnLocalStorage()
markersClusterGroupRef.value?.clearLayers()
formattedData.value?.forEach((row) => {
const primaryGeoDataColumnTitle = geoDataFieldColumn.value?.title
if (primaryGeoDataColumnTitle == null) {
throw new Error('Cannot find primary geo data column title')
}
const primaryGeoDataValue = row.row[primaryGeoDataColumnTitle]
if (primaryGeoDataValue == null) {
return
}
const [lat, long] = primaryGeoDataValue.split(';').map(parseFloat)
addMarker(lat, long, row)
})
})
watch(view, async (nextView) => {
if (nextView?.type === ViewTypes.MAP) {
await loadMapMeta()
await loadMapData()
}
})
const count = computed(() => paginationData.value.totalRows)
</script>
<template>
<a-modal v-model:visible="popupIsOpen" :footer="null" centered :closable="false" @close="popupIsOpen = false">
<LazySmartsheetSharedMapMarkerPopup v-if="popUpRow" :fields="fields" :row="popUpRow"></LazySmartsheetSharedMapMarkerPopup>
</a-modal>
<div class="flex flex-col h-full w-full no-underline" data-testid="nc-map-wrapper">
<div id="mapContainer" ref="mapContainerRef" class="w-full h-screen">
<a-tooltip placement="bottom" class="h-2 w-auto max-w-fit-content absolute top-3 right-3 p-2 z-500 cursor-default">
<template #title>
<span v-if="count > 1000"> {{ $t('msg.info.map.overLimit') }} </span>
<span v-else-if="count > 900"> {{ $t('msg.info.map.closeLimit') }} </span>
<span> {{ $t('msg.info.map.limitNumber') }} </span>
</template>
<div v-if="count > 900" class="nc-warning-info flex min-w-32px h-32px items-center gap-1 px-2 bg-white">
<div>{{ count }} {{ $t('objects.records') }}</div>
<component :is="iconMap.markerAlert" />
</div>
</a-tooltip>
</div>
</div>
<Suspense v-if="!isPublic">
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:row="expandedFormRow"
:state="expandedFormRowState"
:meta="meta"
:view="view"
/>
</Suspense>
<Suspense v-if="!isPublic">
<LazySmartsheetExpandedForm
v-if="expandedFormOnRowIdDlg"
:key="route.query.rowId"
v-model="expandedFormOnRowIdDlg"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:meta="meta"
:row-id="route.query.rowId"
:view="view"
/>
</Suspense>
</template>
<style scoped lang="scss">
:global(.geo-map-marker-cluster) {
background-color: pink;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
</style>
<style>
.no-underline a {
text-decoration: none !important;
}
.leaflet-popup-content-wrapper {
max-height: 255px;
overflow: scroll;
}
.popup-content {
user-select: text;
display: flex;
gap: 10px;
flex-direction: column;
}
</style>

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ChangePageInj, PaginationDataInj, computed, inject } from '#imports'
import { ChangePageInj, PaginationDataInj, computed, iconMap, inject } from '#imports'
const paginatedData = inject(PaginationDataInj)!
@ -39,7 +39,7 @@ const page = computed({
<span class="text-xs" style="white-space: nowrap"> Change page:</span>
<a-input :value="page" size="small" class="ml-1 !text-xs" type="number" @keydown.enter="changePage(page)">
<template #suffix>
<MdiKeyboardReturn class="mt-1" @click="changePage(page)" />
<component :is="iconMap.returnKey" class="mt-1" @click="changePage(page)" />
</template>
</a-input>
</div>

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

@ -22,7 +22,7 @@ const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
// on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => {
@ -49,6 +49,7 @@ provide(ReloadRowDataHookInj, reloadHook)
defineExpose({
syncLTARRefs,
clearLTARCell,
addLTARRef,
})
</script>

94
packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue

@ -0,0 +1,94 @@
<script lang="ts" setup>
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { isVirtualCol } from 'nocodb-sdk'
import {
ActiveViewInj,
ChangePageInj,
FieldsInj,
IsFormInj,
IsGridInj,
MetaInj,
PaginationDataInj,
ReloadRowDataHookInj,
ReloadViewDataHookInj,
inject,
isLTAR,
onMounted,
provide,
ref,
useViewData,
} from '#imports'
import type { Row } from '~/lib'
const props = defineProps<{
fields: ColumnType[]
row: Row
}>()
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const reloadViewDataHook = inject(ReloadViewDataHookInj)
const { loadData, paginationData, changePage } = useViewData(meta, view)
provide(IsFormInj, ref(false))
provide(IsGridInj, ref(false))
provide(PaginationDataInj, paginationData)
provide(ChangePageInj, changePage)
const fields = inject(FieldsInj, ref([]))
const isRowEmpty = (record: any, col: any) => {
const val = record.row[col.title]
if (!val) return true
return Array.isArray(val) && val.length === 0
}
reloadViewDataHook?.on(async () => {
await loadData()
})
onMounted(async () => {
await loadData()
})
provide(ReloadRowDataHookInj, reloadViewDataHook)
const currentRow = toRef(props, 'row')
const { row } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
</script>
<template>
<LazySmartsheetRow :row="row">
<a-card
hoverable
class="!rounded-lg h-full overflow-hidden break-all max-w-[450px]"
:data-testid="`nc-shared-map-marker-popup-card-${row.row.id}`"
>
<div v-for="col in fields" :key="`record-${row.row.id}-${col.id}`">
<div
v-if="!isRowEmpty(row, col) || isLTAR(col.uidt)"
class="flex flex-col space-y-1 px-4 mb-6 bg-gray-50 rounded-lg w-full"
>
<div class="flex flex-row w-full justify-start border-b-1 border-gray-100 py-2.5">
<div class="w-full text-gray-600">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="true" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="true" />
</div>
</div>
<div class="flex flex-row w-full pb-3 pt-2 pl-2 items-center justify-start">
<LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="row.row[col.title]" :column="col" :row="row" />
<LazySmartsheetCell v-else v-model="row.row[col.title]" :column="col" :edit-enabled="false" :read-only="true" />
</div>
</div>
</div>
</a-card>
</LazySmartsheetRow>
</template>

26
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -1,10 +1,12 @@
<script setup lang="ts">
import { IsPublicInj, inject, ref, useSharedView, useSidebar, useSmartsheetStoreOrThrow, useUIPermission } from '#imports'
const { isGrid, isForm, isGallery, isKanban, isSqlView } = useSmartsheetStoreOrThrow()
const { isGrid, isForm, isGallery, isKanban, isMap, isSqlView } = useSmartsheetStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const { isMobileMode } = useGlobal()
const { isUIAllowed } = useUIPermission()
const { isOpen } = useSidebar('nc-right-sidebar')
@ -14,11 +16,12 @@ const { allowCSVDownload } = useSharedView()
<template>
<div
class="nc-table-toolbar w-full py-1 flex gap-2 items-center h-[var(--toolbar-height)] px-2 border-b overflow-x-hidden"
class="nc-table-toolbar w-full py-1 flex gap-2 items-center px-2 border-b overflow-x-hidden"
:class="{ 'nc-table-toolbar-mobile': isMobileMode, 'h-[var(--toolbar-height)]': !isMobileMode }"
style="z-index: 7"
>
<LazySmartsheetToolbarViewActions
v-if="(isGrid || isGallery || isKanban) && !isPublic && isUIAllowed('dataInsert')"
v-if="(isGrid || isGallery || isKanban || isMap) && !isPublic && isUIAllowed('dataInsert')"
:show-system-fields="false"
class="ml-1"
/>
@ -29,18 +32,22 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban" :show-system-fields="false" />
<LazySmartsheetToolbarMappedBy v-if="isMap" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban || isMap" :show-system-fields="false" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarRowHeight v-if="isGrid" />
<LazySmartsheetToolbarShareView v-if="(isForm || isGrid || isKanban || isGallery) && !isPublic" />
<LazySmartsheetToolbarShareView v-if="(isForm || isGrid || isKanban || isGallery || isMap) && !isPublic" />
<LazySmartsheetToolbarQrScannerButton v-if="isMobileMode && (isGrid || isKanban || isGallery)" />
<LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<div class="flex-1" />
<div v-if="!isMobileMode" class="flex-1" />
<LazySmartsheetToolbarReload v-if="!isPublic && !isForm" />
@ -49,7 +56,7 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery || isKanban) && !isPublic" class="shrink mx-2" />
<template v-if="!isOpen && !isPublic">
<div class="border-l-1 pl-3">
<div class="border-l-1 pl-3 nc-views-show-sidebar-button" :class="{ 'ml-auto': isMobileMode }">
<LazySmartsheetSidebarToolbarToggleDrawer class="mr-2" />
</div>
</template>
@ -64,4 +71,7 @@ const { allowCSVDownload } = useSharedView()
.nc-table-toolbar {
border-color: #f0f0f0 !important;
}
.nc-table-toolbar-mobile {
@apply flex-wrap h-auto py-2;
}
</style>

4
packages/nc-gui/components/smartsheet/column/BarcodeOptions.vue

@ -2,7 +2,7 @@
import type { UITypes } from 'nocodb-sdk'
import { AllowedColumnTypesForQrAndBarcodes } from 'nocodb-sdk'
import type { SelectProps } from 'ant-design-vue'
import { onMounted, useVModel, watch } from '#imports'
import { iconMap, onMounted, useVModel, watch } from '#imports'
const props = defineProps<{
modelValue: any
@ -98,7 +98,7 @@ const showBarcodeValueColumnInfoIcon = computed(() => !columnsAllowedAsBarcodeVa
Decimal. Please create one first.
</span>
</template>
<mdi-information class="cursor-pointer" />
<component :is="iconMap.info" class="cursor-pointer" />
</a-tooltip>
</div>
</div>

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

@ -41,6 +41,8 @@ const { $e } = useNuxtApp()
const { appInfo } = useGlobal()
const { betaFeatureToggleState } = useBetaFeatureToggle()
const meta = inject(MetaInj, ref())
const isForm = inject(IsFormInj, ref(false))
@ -57,9 +59,13 @@ const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup]
const geoDataToggleCondition = (t: { name: UITypes }) => {
return betaFeatureToggleState.show ? betaFeatureToggleState.show : !t.name.includes(UITypes.GeoData)
}
const uiTypesOptions = computed<typeof uiTypes>(() => {
return [
...uiTypes.filter((t) => !isEdit.value || !t.virtual),
...uiTypes.filter((t) => geoDataToggleCondition(t) && (!isEdit.value || !t.virtual)),
...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
? [
{
@ -80,8 +86,12 @@ const reloadMetaAndData = async () => {
}
}
const saving = ref(false)
async function onSubmit() {
saving.value = true
const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition)
saving.value = false
if (!saved) return
@ -178,6 +188,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="formState" />
<LazySmartsheetColumnGeoDataOptions v-if="formState.uidt === UITypes.GeoData" v-model:value="formState" />
<LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" v-model:value="formState" />
<LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />
@ -223,7 +234,10 @@ useEventListener('keydown', (e: KeyboardEvent) => {
v-model:value="formState"
/>
<LazySmartsheetColumnAdvancedOptions v-model:value="formState" :advanced-db-options="advancedDbOptions" />
<LazySmartsheetColumnAdvancedOptions
v-model:value="formState"
:advanced-db-options="advancedDbOptions || formState.uidt === UITypes.SpecificDBType"
/>
</div>
</Transition>
@ -234,7 +248,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
{{ $t('general.cancel') }}
</a-button>
<a-button html-type="submit" type="primary" @click.prevent="onSubmit">
<a-button html-type="submit" type="primary" :loading="saving" @click.prevent="onSubmit">
<!-- Save -->
{{ $t('general.save') }}
</a-button>

5
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -12,6 +12,7 @@ import {
formulas,
getUIDTIcon,
getWordUntilCaret,
iconMap,
insertAtCursor,
onMounted,
useColumnCreateStoreOrThrow,
@ -742,9 +743,9 @@ onMounted(() => {
</template>
<template #avatar>
<mdi-function v-if="item.type === 'function'" class="text-lg" />
<component :is="iconMap.function" v-if="item.type === 'function'" class="text-lg" />
<mdi-calculator v-if="item.type === 'op'" class="text-lg" />
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="text-lg" />
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
</template>

16
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ModelTypes, MssqlUi, SqliteUi } from 'nocodb-sdk'
import { MetaInj, inject, ref, useProject, useVModel } from '#imports'
import { MetaInj, inject, ref, storeToRefs, useProject, useVModel } from '#imports'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
@ -10,13 +10,15 @@ const props = defineProps<{
const emit = defineEmits(['update:value'])
const { appInfo } = $(useGlobal())
const vModel = useVModel(props, 'value', emit)
const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, sqlUi } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject())
const { tables } = $(storeToRefs(useProject()))
setAdditionalValidations({
childId: [{ required: true, message: 'Required' }],
@ -31,10 +33,10 @@ if (!vModel.value.childTable) vModel.value.childTable = meta?.table_name
if (!vModel.value.parentTable) vModel.value.parentTable = vModel.value.rtn || ''
if (!vModel.value.parentColumn) vModel.value.parentColumn = vModel.value.rcn || ''
if (!vModel.value.type) vModel.value.type = 'hm'
if (!vModel.value.type) vModel.value.type = 'mm'
if (!vModel.value.onUpdate) vModel.value.onUpdate = onUpdateDeleteOptions[0]
if (!vModel.value.onDelete) vModel.value.onDelete = onUpdateDeleteOptions[0]
if (!vModel.value.virtual) vModel.value.virtual = sqlUi === SqliteUi
if (!vModel.value.virtual) vModel.value.virtual = appInfo.isCloud || sqlUi === SqliteUi
if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name
const advancedOptions = $(ref(false))
@ -55,7 +57,7 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
<div class="border-2 p-6">
<a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type">
<a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type">
<a-radio value="hm">Has Many</a-radio>
<a-radio value="hm" :disabled="appInfo.isCloud">Has Many</a-radio>
<a-radio value="mm">Many To Many</a-radio>
</a-radio-group>
</a-form-item>
@ -127,7 +129,9 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
<div class="flex flex-row">
<a-form-item>
<a-checkbox v-model:checked="vModel.virtual" name="virtual" @change="onDataTypeChange">Virtual Relation</a-checkbox>
<a-checkbox v-model:checked="vModel.virtual" :disabled="appInfo.isCloud" name="virtual" @change="onDataTypeChange"
>Virtual Relation</a-checkbox
>
</a-form-item>
</div>
</div>

4
packages/nc-gui/components/smartsheet/column/LookupOptions.vue

@ -3,7 +3,7 @@ import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn } from 'nocodb-sdk'
import { getRelationName } from './utils'
import { MetaInj, inject, ref, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
import { MetaInj, inject, ref, storeToRefs, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
const props = defineProps<{
value: any
@ -17,7 +17,7 @@ const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject())
const { tables } = $(storeToRefs(useProject()))
const { metas } = $(useMetas())

4
packages/nc-gui/components/smartsheet/column/RollupOptions.vue

@ -3,7 +3,7 @@ import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { getRelationName } from './utils'
import { MetaInj, inject, ref, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
import { MetaInj, inject, ref, storeToRefs, useColumnCreateStoreOrThrow, useMetas, useProject, useVModel } from '#imports'
const props = defineProps<{
value: any
@ -17,7 +17,7 @@ const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject())
const { tables } = $(storeToRefs(useProject()))
const { metas } = $(useMetas())

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

@ -1,7 +1,7 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import { UITypes } from 'nocodb-sdk'
import { IsKanbanInj, enumColor, onMounted, useColumnCreateStoreOrThrow, useVModel, watch } from '#imports'
import { IsKanbanInj, enumColor, iconMap, onMounted, useColumnCreateStoreOrThrow, useVModel, watch } from '#imports'
interface Option {
color: string
@ -172,7 +172,8 @@ watch(inputs, () => {
:data-testid="`select-column-option-${index}`"
:class="{ removed: element.status === 'remove' }"
>
<MdiDragVertical
<component
:is="iconMap.dragVertical"
v-if="!isKanban"
small
class="nc-child-draggable-icon handle"
@ -208,7 +209,8 @@ watch(inputs, () => {
/>
</div>
<MdiClose
<component
:is="iconMap.close"
v-if="element.status !== 'remove'"
class="ml-2 hover:!text-black-500 text-gray-500 cursor-pointer"
:data-testid="`select-column-option-remove-${index}`"
@ -231,7 +233,7 @@ watch(inputs, () => {
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewOption()">
<div class="flex items-center">
<MdiPlus />
<component :is="iconMap.plus" />
<span class="flex-auto">Add option</span>
</div>
</a-button>

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

@ -1,3 +1,3 @@
<template>
<div class="mt-4 mb-2" />
<div />
</template>

203
packages/nc-gui/components/smartsheet/expanded-form/Comments.vue

@ -1,14 +1,108 @@
<script setup lang="ts">
import { enumColor, ref, timeAgo, useExpandedFormStoreOrThrow, watch } from '#imports'
import type { VNodeRef } from '@vue/runtime-core'
import type { AuditType } from 'nocodb-sdk'
import { enumColor, iconMap, ref, timeAgo, useCopy, useExpandedFormStoreOrThrow, useGlobal, useI18n, watch } from '#imports'
const { loadCommentsAndLogs, commentsAndLogs, isCommentsLoading, commentsOnly, saveComment, isYou, comment } =
const { loadCommentsAndLogs, commentsAndLogs, isCommentsLoading, commentsOnly, saveComment, isYou, comment, updateComment } =
useExpandedFormStoreOrThrow()
const commentsWrapperEl = ref<HTMLDivElement>()
await loadCommentsAndLogs()
const showborder = ref(false)
const showBorder = ref(false)
const { copy } = useCopy()
const { t } = useI18n()
const { user } = useGlobal()
const { isUIAllowed } = useUIPermission()
const hasEditPermission = $computed(() => isUIAllowed('commentEditable'))
// currently, edit option is disable on purpose
// since the current update wouldn't keep track of the previous values
// need history of edit feature in order to enable it back
const disableEditOption = ref(true)
let editLog = $ref<AuditType>()
let isEditing = $ref<boolean>(false)
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onKeyEsc(event)
} else if (event.key === 'Enter') {
onKeyEnter(event)
}
}
function onKeyEnter(event: KeyboardEvent) {
event.stopImmediatePropagation()
event.preventDefault()
onEditComment()
}
function onKeyEsc(event: KeyboardEvent) {
event.stopImmediatePropagation()
event.preventDefault()
onCancel()
}
async function onEditComment() {
if (!isEditing || !editLog) return
await updateComment(editLog.id!, {
description: editLog.description,
})
onStopEdit()
}
function onCancel() {
if (!isEditing) return
editLog = undefined
onStopEdit()
}
function onStopEdit() {
isEditing = false
editLog = undefined
}
onKeyStroke('Enter', (event) => {
if (isEditing) {
onKeyEnter(event)
}
})
const _contextMenu = ref(false)
const contextMenu = computed({
get: () => _contextMenu.value,
set: (val) => {
if (hasEditPermission) {
_contextMenu.value = val
}
},
})
async function copyComment(val: string) {
if (!val) return
try {
await copy(val)
message.success(t('msg.success.commentCopied'))
} catch (e: any) {
message.error(e.message)
}
}
function editComment(log: AuditType) {
editLog = log
isEditing = true
}
watch(
commentsAndLogs,
@ -25,32 +119,72 @@ watch(
<template>
<div class="h-full flex flex-col w-full bg-[#eceff1] p-2">
<div ref="commentsWrapperEl" class="flex-1 min-h-[100px] overflow-y-auto scrollbar-thin-dull p-2 space-y-2">
<a-skeleton v-if="isCommentsLoading && !commentsAndLogs" type="list-item-avatar-two-line@8" />
<template v-else>
<div v-for="log of commentsAndLogs" :key="log.id" class="flex gap-1 text-xs">
<MdiAccountCircle class="row-span-2" :class="isYou(log.user) ? 'text-pink-300' : 'text-blue-300 '" />
<div class="flex-1">
<p class="mb-1 caption edited-text text-[10px] text-gray-500">
{{ isYou(log.user) ? 'You' : log.user == null ? 'Shared base' : log.user }}
{{ log.op_type === 'COMMENT' ? 'commented' : log.op_sub_type === 'INSERT' ? 'created' : 'edited' }}
</p>
<p
v-if="log.op_type === 'COMMENT'"
class="block caption my-2 nc-chip w-full min-h-20px p-2 rounded"
:style="{ backgroundColor: enumColor.light[2] }"
>
{{ log.description }}
</p>
<p v-else v-dompurify-html="log.details" class="caption my-3" style="word-break: break-all" />
<p class="time text-right text-[10px] mb-0 mt-1 text-gray-500">
{{ timeAgo(log.created_at) }}
</p>
<a-skeleton v-if="isCommentsLoading" type="list-item-avatar-two-line@8" />
<template v-else-if="commentsAndLogs.length === 0">
<div class="flex flex-col text-center justify-center h-full">
<div class="text-center text-3xl">
<MdiChatProcessingOutline />
</div>
<div class="font-bold text-center my-1">Start a conversation</div>
<div>NocoDB allows you to inquire, monitor progress updates, and collaborate with your team members.</div>
</div>
</template>
<template v-else>
<div v-for="(log, idx) of commentsAndLogs" :key="log.id">
<a-dropdown :trigger="['contextmenu']" :overlay-class-name="`nc-dropdown-comment-context-menu-${idx}`">
<div class="flex gap-1 text-xs">
<component
:is="iconMap.accountCircle"
class="row-span-2"
:class="isYou(log.user) ? 'text-pink-300' : 'text-blue-300 '"
/>
<div class="flex-1">
<p class="mb-1 caption edited-text text-[10px] text-gray-500">
{{ isYou(log.user) ? 'You' : log.user == null ? 'Shared base' : log.user }}
{{ log.op_type === 'COMMENT' ? 'commented' : log.op_sub_type === 'INSERT' ? 'created' : 'edited' }}
</p>
<div v-if="log.op_type === 'COMMENT'">
<a-input
v-if="log.id === editLog?.id"
:ref="focusInput"
v-model:value="editLog.description"
@blur="onCancel"
@keydown.stop="onKeyDown($event)"
/>
<p
v-else
class="block caption my-2 nc-chip w-full min-h-20px p-2 rounded"
:style="{ backgroundColor: enumColor.light[2] }"
>
{{ log.description }}
</p>
</div>
<p v-else v-dompurify-html="log.details" class="caption my-3" style="word-break: break-all" />
<p class="time text-right text-[10px] mb-0 mt-1 text-gray-500">
{{ timeAgo(log.created_at) }}
</p>
</div>
</div>
<template #overlay>
<a-menu v-if="log.op_type === 'COMMENT'" @click="contextMenu = false">
<a-menu-item key="copy-comment" @click="copyComment(log.description)">
<div v-e="['a:comment:copy']" class="nc-project-menu-item">
{{ t('general.copy') }}
</div>
</a-menu-item>
<a-menu-item v-if="log.user === user.email && !disableEditOption" key="edit-comment" @click="editComment(log)">
<div v-e="['a:comment:edit']" class="nc-project-menu-item">
{{ t('general.edit') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
</div>
@ -59,10 +193,9 @@ watch(
<div class="p-0">
<div class="flex justify-center">
<!-- Comments only -->
<!-- Comments only -->
<a-checkbox v-model:checked="commentsOnly" v-e="['c:row-expand:comment-only']" @change="loadCommentsAndLogs">
{{ $t('labels.commentsOnly') }}
<span class="text-[11px] text-gray-500" />
</a-checkbox>
</div>
@ -72,19 +205,19 @@ watch(
v-model:value="comment"
class="!text-xs nc-comment-box"
ghost
:class="{ focus: showborder }"
@focusin="showborder = true"
@focusout="showborder = false"
:class="{ focus: showBorder }"
@focusin="showBorder = true"
@focusout="showBorder = false"
@keyup.enter.prevent="saveComment"
>
<template #addonBefore>
<div class="flex items-center">
<mdi-account-circle class="text-lg text-pink-300" small @click="saveComment" />
<component :is="iconMap.accountCircle" class="text-lg text-pink-300" small @click="saveComment" />
</div>
</template>
<template #suffix>
<mdi-keyboard-return v-if="comment" class="text-sm" small @click="saveComment" />
<component :is="iconMap.returnKey" v-if="comment" class="text-sm" small @click="saveComment" />
</template>
</a-input>
</div>

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

@ -3,6 +3,7 @@ import { message } from 'ant-design-vue'
import type { ViewType } from 'nocodb-sdk'
import {
ReloadRowDataHookInj,
iconMap,
isMac,
useExpandedFormStoreOrThrow,
useSmartsheetRowStoreOrThrow,
@ -18,7 +19,7 @@ const route = useRoute()
const { meta, isSqlView } = useSmartsheetStoreOrThrow()
const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow } = useExpandedFormStoreOrThrow()
const { commentsDrawer, displayValue, primaryKey, save: _save, loadRow, saveRowAndStay } = useExpandedFormStoreOrThrow()
const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow()
@ -26,8 +27,6 @@ const { isUIAllowed } = useUIPermission()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const saveRowAndStay = ref(0)
const save = async () => {
if (isNew.value) {
const data = await _save(state.value)
@ -103,23 +102,13 @@ const onConfirmDeleteRowClick = async () => {
</h5>
<div class="flex-1" />
<a-tooltip placement="bottom">
<template #title>
<div class="text-center w-full">{{ $t('general.reload') }}</div>
</template>
<mdi-reload
v-if="!isNew"
class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 min-w-4"
@click="loadRow"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
<!-- todo: i18n -->
<div class="text-center w-full">Copy record URL</div>
</template>
<mdi-link
<component
:is="iconMap.link"
v-if="!isNew"
class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 nc-copy-row-url min-w-4"
@click="copyRecordUrl"
@ -131,7 +120,8 @@ const onConfirmDeleteRowClick = async () => {
<template #title>
<div class="text-center w-full">{{ $t('activity.toggleCommentsDraw') }}</div>
</template>
<MdiCommentTextOutline
<component
:is="iconMap.comment"
v-if="isUIAllowed('rowComments') && !isNew"
v-e="['c:row-expand:comment-toggle']"
class="nc-icon-transition cursor-pointer select-none nc-toggle-comments text-gray-500 mx-1 min-w-4"
@ -139,72 +129,75 @@ const onConfirmDeleteRowClick = async () => {
/>
</a-tooltip>
<a-tooltip v-if="!isSqlView" placement="bottom">
<!-- Duplicate row -->
<template #title>
<div class="text-center w-full">{{ $t('activity.duplicateRow') }}</div>
</template>
<MdiContentCopy
v-if="isUIAllowed('xcDatatableEditable') && !isNew"
v-e="['c:row-expand:duplicate']"
class="nc-icon-transition cursor-pointer select-none nc-duplicate-row text-gray-500 mx-1 min-w-4"
@click="!isNew && emit('duplicateRow')"
/>
</a-tooltip>
<a-tooltip v-if="!isSqlView" placement="bottom">
<!-- Delete row -->
<template #title>
<div class="text-center w-full">{{ $t('activity.deleteRow') }}</div>
</template>
<MdiDeleteOutline
v-if="isUIAllowed('xcDatatableEditable') && !isNew"
v-e="['c:row-expand:delete']"
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4"
@click="!isNew && onDeleteRowClick()"
/>
</a-tooltip>
<a-dropdown-button class="nc-expand-form-save-btn" type="primary" :disabled="!isUIAllowed('tableRowUpdate')" @click="save">
<template #icon><MdiMenuDown /></template>
<template #icon><component :is="iconMap.arrowDown" /></template>
<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" />
<component :is="iconMap.contentSaveExit" 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" />
<component :is="iconMap.contentSaveStay" 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" />
<component :is="iconMap.contentSaveExit" class="mr-1" />
{{ $t('activity.saveAndExit') }}
</div>
<div v-if="saveRowAndStay === 1" class="flex items-center">
<MdiContentSaveEdit class="mr-1" />
<component :is="iconMap.contentSaveStay" class="mr-1" />
{{ $t('activity.saveAndStay') }}
</div>
</a-dropdown-button>
<a-tooltip placement="bottom">
<!-- Close -->
<template #title>
<div class="text-center w-full">{{ $t('general.close') }}</div>
<a-dropdown>
<component :is="iconMap.threeDotVertical" class="nc-icon-transition" />
<template #overlay>
<a-menu>
<a-menu-item v-if="!isNew" @click="loadRow">
<div v-e="['c:row-expand:reload']" class="py-2 flex gap-2 items-center">
<component :is="iconMap.reload" class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 min-w-4" />
{{ $t('general.reload') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('xcDatatableEditable') && !isNew" @click="!isNew && emit('duplicateRow')">
<div v-e="['c:row-expand:duplicate']" class="py-2 flex gap-2 a">
<component
:is="iconMap.copy"
class="nc-icon-transition cursor-pointer select-none nc-duplicate-row text-gray-500 mx-1 min-w-4"
/>
{{ $t('activity.duplicateRow') }}
</div>
</a-menu-item>
<a-menu-item v-if="isUIAllowed('xcDatatableEditable') && !isNew" @click="!isNew && onDeleteRowClick()">
<div v-e="['c:row-expand:delete']" class="py-2 flex gap-2 items-center">
<component
:is="iconMap.delete"
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4"
/>
{{ $t('activity.deleteRow') }}
</div>
</a-menu-item>
<a-menu-item @click="emit('cancel')">
<div v-e="['c:row-expand:delete']" class="py-2 flex gap-2 items-center">
<component
:is="iconMap.closeCircle"
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4"
/>
{{ $t('general.close') }}
</div>
</a-menu-item>
</a-menu>
</template>
<MdiCloseCircleOutline
class="nc-icon-transition cursor-pointer select-none nc-close-form text-gray-500 mx-1 min-w-4"
@click="emit('cancel')"
/>
</a-tooltip>
</a-dropdown>
<a-modal v-model:visible="showDeleteRowModal" title="Delete row?" @ok="onConfirmDeleteRowClick">
<p>Are you sure you want to delete this row?</p>
</a-modal>

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

@ -3,6 +3,7 @@ import type { TableType, ViewType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import {
CellClickHookInj,
FieldsInj,
IsFormInj,
IsKanbanInj,
@ -10,6 +11,7 @@ import {
ReloadRowDataHookInj,
computedInject,
createEventHook,
iconMap,
inject,
message,
provide,
@ -21,6 +23,7 @@ import {
useVModel,
watch,
} from '#imports'
import { useActiveKeyupListener } from '~/composables/useSelectedCellKeyupListener'
import type { Row } from '~/lib'
interface Props {
@ -33,12 +36,16 @@ interface Props {
rowId?: string
view?: ViewType
showNextPrevIcons?: boolean
firstRow?: boolean
lastRow?: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev'])
const key = ref(0)
const { t } = useI18n()
const row = ref(props.row)
@ -49,6 +56,9 @@ const meta = toRef(props, 'meta')
const router = useRouter()
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, null)
const fields = computedInject(FieldsInj, (_fields) => {
if (props.useMetaFields) {
return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col))
@ -60,7 +70,16 @@ const isKanban = inject(IsKanbanInj, ref(false))
provide(MetaInj, meta)
const { commentsDrawer, changedColumns, state: rowState, isNew, loadRow } = useProvideExpandedFormStore(meta, row)
const {
commentsDrawer,
changedColumns,
state: rowState,
isNew,
loadRow,
saveRowAndStay,
syncLTARRefs,
save,
} = useProvideExpandedFormStore(meta, row)
const duplicatingRowInProgress = ref(false)
@ -122,6 +141,25 @@ const onDuplicateRow = () => {
}, 500)
}
const onNext = async () => {
if (changedColumns.value.size > 0) {
await Modal.confirm({
title: 'Do you want to save the changes?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
await save()
emits('next')
},
onCancel: () => {
emits('next')
},
})
} else {
emits('next')
}
}
const reloadParentRowHook = inject(ReloadRowDataHookInj, createEventHook())
// override reload trigger and use it to reload grid and the form itself
@ -132,7 +170,6 @@ reloadHook.on(() => {
if (isNew.value) return
loadRow()
})
provide(ReloadRowDataHookInj, reloadHook)
if (isKanban.value) {
@ -147,10 +184,94 @@ if (isKanban.value) {
const cellWrapperEl = ref<HTMLElement>()
onMounted(() => {
setTimeout(() => {
;(cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus()
})
setTimeout(() => (cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus())
})
const addNewRow = () => {
setTimeout(async () => {
row.value = {
row: {},
oldRow: {},
rowMeta: { new: true },
}
rowState.value = {}
key.value++
isExpanded.value = true
}, 500)
}
// attach keyboard listeners to switch between rows
// using alt + left/right arrow keys
useActiveKeyupListener(
isExpanded,
async (e: KeyboardEvent) => {
if (!e.altKey) return
if (e.key === 'ArrowLeft') {
e.stopPropagation()
emits('prev')
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
onNext()
}
// on alt + s save record
else if (e.code === 'KeyS') {
// remove focus from the active input if any
;(document.activeElement as HTMLElement)?.blur()
e.stopPropagation()
if (isNew.value) {
const data = await save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
} else {
await save()
reloadHook?.trigger(null)
}
if (!saveRowAndStay.value) {
onClose()
}
// on alt + n create new record
} else if (e.code === 'KeyN') {
// remove focus from the active input if any to avoid unwanted input
;(document.activeElement as HTMLInputElement)?.blur?.()
if (changedColumns.value.size > 0) {
await Modal.confirm({
title: 'Do you want to save the changes?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
await save()
reloadHook?.trigger(null)
addNewRow()
},
onCancel: () => {
addNewRow()
},
})
} else if (isNew.value) {
await Modal.confirm({
title: 'Do you want to save the record?',
okText: 'Save',
cancelText: 'Discard',
onOk: async () => {
const data = await save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null)
addNewRow()
},
onCancel: () => {
addNewRow()
},
})
} else {
addNewRow()
}
}
},
{ immediate: true },
)
</script>
<script lang="ts">
@ -171,21 +292,25 @@ export default {
>
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" @duplicate-row="onDuplicateRow" />
<div class="!bg-gray-100 rounded flex-1">
<div :key="key" class="!bg-gray-100 rounded flex-1">
<div class="flex h-full nc-form-wrapper items-stretch min-h-[max(70vh,100%)]">
<div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container relative">
<template v-if="props.showNextPrevIcons">
<a-tooltip placement="bottom">
<a-tooltip v-if="!props.firstRow" placement="bottom">
<template #title>
{{ $t('labels.nextRow') }}
{{ $t('labels.prevRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '←']" />
</template>
<MdiChevronRight class="cursor-pointer nc-next-arrow" @click="$emit('next')" />
<GeneralIcon icon="chevronLeft" class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
</a-tooltip>
<a-tooltip placement="bottom">
<a-tooltip v-if="!props.lastRow" placement="bottom">
<template #title>
{{ $t('labels.prevRow') }}
{{ $t('labels.nextRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '→']" />
</template>
<MdiChevronLeft class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" />
<GeneralIcon icon="chevronRight" class="cursor-pointer nc-next-arrow" @click="onNext" />
</a-tooltip>
</template>
<div class="w-[500px] mx-auto">
@ -259,7 +384,7 @@ export default {
.nc-prev-arrow,
.nc-next-arrow {
@apply absolute opacity-70 rounded-full transition-transform transition-background transition-opacity transform bg-white hover:(bg-gray-200) active:(scale-125 opacity-100) text-xl;
@apply w-7 h-7 flex items-center justify-center absolute opacity-70 rounded-full transition-transform transition-background transition-opacity transform bg-white hover:(bg-gray-200) active:(scale-125 opacity-100) !text-xl;
}
.nc-prev-arrow {

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

Loading…
Cancel
Save