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. 62
      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. 11
      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. 25
      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. 17
      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. 45
      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. 161
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  99. 107
      packages/nc-gui/components/smartsheet/expanded-form/Header.vue
  100. 149
      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) - [Development Setup](#development-setup)
* [Committing Changes](#committing-changes) * [Committing Changes](#committing-changes)
* [Applying License](#applying-license) * [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 ## 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. - 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 (e.g. `feat/foo`, `fix/bar`, `enhancement/baz`). All approved PRs will go to `develop` branch. **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.
**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. - 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. - 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 ## Development Setup

41
.github/workflows/ci-cd.yml

@ -63,6 +63,47 @@ jobs:
- name: run unit tests - name: run unit tests
working-directory: ./packages/nocodb working-directory: ./packages/nocodb
run: npm run test:unit 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: playwright-mysql-1:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }} 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 uses: ./.github/workflows/playwright-test-workflow.yml

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

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

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

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

3
.gitignore vendored

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

62
README.md

@ -10,14 +10,13 @@
</h1> </h1>
<p align="center"> <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> </p>
<div align="center"> <div align="center">
[![Build Status](https://travis-ci.org/dwyl/esta.svg?branch=master)](https://travis-ci.com/github/NocoDB/NocoDB) [![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) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-green.svg)](https://conventionalcommits.org)
</div> </div>
@ -47,12 +46,12 @@ Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart-spreadshe
</div> </div>
<p align="center"><a href="markdown/readme/languages/README.md"><b>See other languages »</b></a></p> <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" /> <img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
# Join Our Team # 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> <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 # 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=""> <img src="https://i2.wp.com/www.feverbee.com/wp-content/uploads/2018/07/logo-discourse.png" alt="">
</a> </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 # Quick try
## NPX ## 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 npx create-nocodb-app
@ -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). > 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 ## Binaries
##### MacOS (x64) ##### MacOS (x64)
```bash ```bash
curl http://get.nocodb.com/macos-x64 -o nocodb -L && chmod +x nocodb && ./nocodb curl http://get.nocodb.com/macos-x64 -o nocodb -L && chmod +x nocodb && ./nocodb
``` ```
##### MacOS (arm64) ##### MacOS (arm64)
```bash ```bash
curl http://get.nocodb.com/macos-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb curl http://get.nocodb.com/macos-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb
``` ```
##### Linux (x64) ##### Linux (x64)
```bash ```bash
curl http://get.nocodb.com/linux-x64 -o nocodb -L && chmod +x nocodb && ./nocodb curl http://get.nocodb.com/linux-x64 -o nocodb -L && chmod +x nocodb && ./nocodb
``` ```
##### Linux (arm64) ##### Linux (arm64)
```bash ```bash
curl http://get.nocodb.com/linux-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb curl http://get.nocodb.com/linux-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb
``` ```
##### Windows (x64) ##### Windows (x64)
```bash ```bash
iwr http://get.nocodb.com/win-x64.exe iwr http://get.nocodb.com/win-x64.exe
.\Noco-win-x64.exe .\Noco-win-x64.exe
``` ```
##### Windows (arm64) ##### Windows (arm64)
```bash ```bash
iwr http://get.nocodb.com/win-arm64.exe iwr http://get.nocodb.com/win-arm64.exe
.\Noco-win-arm64.exe .\Noco-win-arm64.exe
@ -202,22 +209,22 @@ Access Dashboard using : [http://localhost:8080/dashboard](http://localhost:8080
# Table of Contents # Table of Contents
- [Quick try](#quick-try) - [Quick try](#quick-try)
* [NPX](#npx) - [NPX](#npx)
* [Node Application](#node-application) - [Node Application](#node-application)
* [Docker](#docker) - [Docker](#docker)
* [Docker Compose](#docker-compose) - [Docker Compose](#docker-compose)
- [GUI](#gui) - [GUI](#gui)
- [Join Our Community](#join-our-community) - [Join Our Community](#join-our-community)
- [Screenshots](#screenshots) - [Screenshots](#screenshots)
- [Table of Contents](#table-of-contents) - [Table of Contents](#table-of-contents)
- [Features](#features) - [Features](#features)
+ [Rich Spreadsheet Interface](#rich-spreadsheet-interface) - [Rich Spreadsheet Interface](#rich-spreadsheet-interface)
+ [App Store for Workflow Automations](#app-store-for-workflow-automations) - [App Store for Workflow Automations](#app-store-for-workflow-automations)
+ [Programmatic Access](#programmatic-access) - [Programmatic Access](#programmatic-access)
+ [Sync Schema](#sync-schema) - [Sync Schema](#sync-schema)
+ [Audit](#audit) - [Audit](#audit)
- [Production Setup](#production-setup) - [Production Setup](#production-setup)
* [Environment variables](#environment-variables) - [Environment variables](#environment-variables)
- [Development Setup](#development-setup) - [Development Setup](#development-setup)
- [Contributing](#contributing) - [Contributing](#contributing)
- [Why are we building this?](#why-are-we-building-this) - [Why are we building this?](#why-are-we-building-this)
@ -229,18 +236,18 @@ Access Dashboard using : [http://localhost:8080/dashboard](http://localhost:8080
### Rich Spreadsheet Interface ### 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;Fields Operations: Sort, Filter, Hide / Unhide Columns
- ⚡ &nbsp;Multiple Views Types: Grid (By default), Gallery, Form View and Kanban View - ⚡ &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;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;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;Access Control with Roles: Fine-grained Access Control at different levels
- ⚡ &nbsp;and more ... - ⚡ &nbsp;and more ...
### App Store for Workflow Automations ### 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;Chat: Slack, Discord, Mattermost, and etc
- ⚡ &nbsp;Email: AWS SES, SMTP, MailerSend, and etc - ⚡ &nbsp;Email: AWS SES, SMTP, MailerSend, and etc
@ -248,26 +255,26 @@ We provide different integrations in three main categories. See <a href="https:/
### Programmatic Access ### 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;REST APIs
- ⚡ &nbsp;NocoDB SDK - ⚡ &nbsp;NocoDB SDK
### Sync Schema ### 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
@ -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). Please refer to [Contribution Guide](https://github.com/nocodb/nocodb/blob/master/.github/CONTRIBUTING.md).
# Why are we building this? # 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
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.
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 # License
<p> <p>
This project is licensed under <a href="./LICENSE">AGPLv3</a>. This project is licensed under <a href="./LICENSE">AGPLv3</a>.
</p> </p>

4
charts/nocodb/templates/pvc.yaml

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

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

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

2
packages/nc-cli/package.json

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

15
packages/nc-gui/app.vue

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { applyNonSelectable, computed, useRoute, useTheme } from '#imports' import { computed, useRoute, useTheme } from '#imports'
const route = useRoute() const route = useRoute()
@ -7,7 +7,18 @@ const disableBaseLayout = computed(() => route.path.startsWith('/nc/view') || ro
useTheme() 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 // TODO: Remove when https://github.com/vuejs/core/issues/5513 fixed
const key = ref(0) const key = ref(0)

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

@ -31,14 +31,3 @@ For Drag and Drop
.grabbing * { .grabbing * {
cursor: 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); @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 { .ant-modal {
@apply !top-[50px]; @apply !top-[50px];
} }

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

@ -149,3 +149,45 @@
font-style: normal; font-style: normal;
font-display: swap; 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'] IcTwotoneWidthNormal: typeof import('~icons/ic/twotone-width-normal')['default']
LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default'] LogosGoogleGmail: typeof import('~icons/logos/google-gmail')['default']
LogosMysqlIcon: typeof import('~icons/logos/mysql-icon')['default'] LogosMysqlIcon: typeof import('~icons/logos/mysql-icon')['default']
LogosOracle: typeof import('~icons/logos/oracle')['default']
LogosPostgresql: typeof import('~icons/logos/postgresql')['default'] LogosPostgresql: typeof import('~icons/logos/postgresql')['default']
LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default'] LogosRedditIcon: typeof import('~icons/logos/reddit-icon')['default']
LogosSnowflakeIcon: typeof import('~icons/logos/snowflake-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'] MaterialSymbolsFileCopyOutline: typeof import('~icons/material-symbols/file-copy-outline')['default']
MaterialSymbolsKeyboardReturn: typeof import('~icons/material-symbols/keyboard-return')['default'] MaterialSymbolsKeyboardReturn: typeof import('~icons/material-symbols/keyboard-return')['default']
MaterialSymbolsLightModeOutline: typeof import('~icons/material-symbols/light-mode-outline')['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'] MaterialSymbolsRocketLaunchOutline: typeof import('~icons/material-symbols/rocket-launch-outline')['default']
MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default'] MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['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'] MdiCardsHeart: typeof import('~icons/mdi/cards-heart')['default']
MdiCellphoneMessage: typeof import('~icons/mdi/cellphone-message')['default'] MdiCellphoneMessage: typeof import('~icons/mdi/cellphone-message')['default']
MdiChat: typeof import('~icons/mdi/chat')['default'] MdiChat: typeof import('~icons/mdi/chat')['default']
MdiChatProcessingOutline: typeof import('~icons/mdi/chat-processing-outline')['default']
MdiCheck: typeof import('~icons/mdi/check')['default'] MdiCheck: typeof import('~icons/mdi/check')['default']
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['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'] MdiCloseCircleOutline: typeof import('~icons/mdi/close-circle-outline')['default']
MdiCloseThick: typeof import('~icons/mdi/close-thick')['default'] MdiCloseThick: typeof import('~icons/mdi/close-thick')['default']
MdiCodeJson: typeof import('~icons/mdi/code-json')['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'] MdiCodeTags: typeof import('~icons/mdi/code-tags')['default']
MdiCog: typeof import('~icons/mdi/cog')['default'] MdiCog: typeof import('~icons/mdi/cog')['default']
MdiCommentTextOutline: typeof import('~icons/mdi/comment-text-outline')['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'] MdiFunction: typeof import('~icons/mdi/function')['default']
MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default'] MdiGestureDoubleTap: typeof import('~icons/mdi/gesture-double-tap')['default']
MdiGithub: typeof import('~icons/mdi/github')['default'] MdiGithub: typeof import('~icons/mdi/github')['default']
MdiGpsFixed: typeof import('~icons/mdi/gps-fixed')['default']
MdiGraphOutline: typeof import('~icons/mdi/graph-outline')['default'] MdiGraphOutline: typeof import('~icons/mdi/graph-outline')['default']
MdiHeart: typeof import('~icons/mdi/heart')['default'] MdiHeart: typeof import('~icons/mdi/heart')['default']
MdiHook: typeof import('~icons/mdi/hook')['default'] MdiHook: typeof import('~icons/mdi/hook')['default']
@ -203,6 +208,8 @@ declare module '@vue/runtime-core' {
MdiLogin: typeof import('~icons/mdi/login')['default'] MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default'] MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['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'] MdiMenu: typeof import('~icons/mdi/menu')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default'] MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
MdiMicrosoftTeams: typeof import('~icons/mdi/microsoft-teams')['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'] MdiPlusCircleOutline: typeof import('~icons/mdi/plus-circle-outline')['default']
MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default'] MdiPlusOutline: typeof import('~icons/mdi/plus-outline')['default']
MdiPlusThick: typeof import('~icons/mdi/plus-thick')['default'] MdiPlusThick: typeof import('~icons/mdi/plus-thick')['default']
MdiQrcodeScan: typeof import('~icons/mdi/qrcode-scan')['default']
MdiReddit: typeof import('~icons/mdi/reddit')['default'] MdiReddit: typeof import('~icons/mdi/reddit')['default']
MdiRefresh: typeof import('~icons/mdi/refresh')['default'] MdiRefresh: typeof import('~icons/mdi/refresh')['default']
MdiReload: typeof import('~icons/mdi/reload')['default'] MdiReload: typeof import('~icons/mdi/reload')['default']
MdiReset: typeof import('~icons/mdi/reset')['default']
MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default'] MdiRocketLaunchOutline: typeof import('~icons/mdi/rocket-launch-outline')['default']
MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-outline')['default'] MdiScriptTextKeyOutline: typeof import('~icons/mdi/script-text-key-outline')['default']
MdiScriptTextOutline: typeof import('~icons/mdi/script-text-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'] MdiTableColumnPlusBefore: typeof import('~icons/mdi/table-column-plus-before')['default']
MdiTableKey: typeof import('~icons/mdi/table-key')['default'] MdiTableKey: typeof import('~icons/mdi/table-key')['default']
MdiTableLarge: typeof import('~icons/mdi/table-large')['default'] MdiTableLarge: typeof import('~icons/mdi/table-large')['default']
MdiTestTube: typeof import('~icons/mdi/test-tube')['default']
MdiText: typeof import('~icons/mdi/text')['default'] MdiText: typeof import('~icons/mdi/text')['default']
MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default'] MdiThumbUp: typeof import('~icons/mdi/thumb-up')['default']
MdiTrashCan: typeof import('~icons/mdi/trash-can')['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'] NcIconsRowHeightMedium: typeof import('~icons/nc-icons/row-height-medium')['default']
NcIconsRowHeightShort: typeof import('~icons/nc-icons/row-height-short')['default'] NcIconsRowHeightShort: typeof import('~icons/nc-icons/row-height-short')['default']
NcIconsRowHeightTall: typeof import('~icons/nc-icons/row-height-tall')['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'] 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'] 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'] RiLineHeight: typeof import('~icons/ri/line-height')['default']
RiTeamFill: typeof import('~icons/ri/team-fill')['default'] RiTeamFill: typeof import('~icons/ri/team-fill')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

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

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

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <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 }) const { api, error } = useApi({ useGlobalInstance: true })
@ -54,7 +54,7 @@ const passwordChange = async () => {
message.success(t('msg.success.passwordChanged')) message.success(t('msg.success.passwordChanged'))
signOut() await signOut()
navigateTo('/signin') navigateTo('/signin')
} }
@ -121,7 +121,7 @@ const resetError = () => {
<div class="text-center"> <div class="text-center">
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit"> <button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<MdiKeyChange /> <component :is="iconMap.passwordChange" />
{{ $t('activity.changePwd') }} {{ $t('activity.changePwd') }}
</span> </span>
</button> </button>

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,5 +1,14 @@
<script setup lang="ts"> <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 { interface Props {
// If the previous cell value was a text, the initial checkbox value is a string type // 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', unchecked: 'mdi-checkbox-blank-circle-outline',
}, },
color: 'primary', color: 'primary',
...(column?.value?.meta || {}), ...parseProp(column?.value?.meta),
} }
}) })
@ -89,7 +98,7 @@ useSelectedCellKeyupListener(active, (e) => {
<style scoped lang="scss"> <style scoped lang="scss">
.nc-cell-hover-show { .nc-cell-hover-show {
opacity: 0; opacity: 0.3;
transition: 0.3s opacity; transition: 0.3s opacity;
&:hover { &:hover {

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

@ -29,9 +29,9 @@ onMounted(() => {
--> -->
<text-clamp <text-clamp
:key="`clamp-${key}-${props.value?.toString().length || 0}`" :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 || ' '}`" :text="`${props.value || ' '}`"
:max-lines="props.lines" :max-lines="props.lines || 1"
/> />
</div> </div>
</template> </template>

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core' 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 { interface Props {
modelValue: number | null | undefined modelValue: number | null | undefined
@ -35,7 +35,7 @@ const currencyMeta = computed(() => {
return { return {
currency_locale: 'en-US', currency_locale: 'en-US',
currency_code: 'USD', currency_code: 'USD',
...(column.value.meta ? column.value.meta : {}), ...parseProp(column?.value?.meta),
} }
}) })
@ -81,6 +81,7 @@ onMounted(() => {
@keydown.delete.stop @keydown.delete.stop
@selectstart.capture.stop @selectstart.capture.stop
@mousedown.stop @mousedown.stop
@contextmenu.stop
/> />
<span v-else-if="vModel === null && showNull" class="nc-null">NULL</span> <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, computed,
inject, inject,
isDrawerOrModalExist, isDrawerOrModalExist,
parseProp,
ref, ref,
useSelectedCellKeyupListener, useSelectedCellKeyupListener,
watch, watch,
@ -34,7 +35,7 @@ const editable = inject(EditModeInj, ref(false))
let isDateInvalid = $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({ let localState = $computed({
get() { get() {

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

@ -7,6 +7,7 @@ import {
dateFormats, dateFormats,
inject, inject,
isDrawerOrModalExist, isDrawerOrModalExist,
parseProp,
ref, ref,
timeFormats, timeFormats,
useProject, useProject,
@ -38,8 +39,8 @@ const column = inject(ColumnInj)!
let isDateInvalid = $ref(false) let isDateInvalid = $ref(false)
const dateTimeFormat = $computed(() => { const dateTimeFormat = $computed(() => {
const dateFormat = column?.value?.meta?.date_format ?? dateFormats[0] const dateFormat = parseProp(column?.value?.meta)?.date_format ?? dateFormats[0]
const timeFormat = column?.value?.meta?.time_format ?? timeFormats[0] const timeFormat = parseProp(column?.value?.meta)?.time_format ?? timeFormats[0]
return `${dateFormat} ${timeFormat}` 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' import { EditModeInj, inject, useVModel } from '#imports'
interface Props { 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 modelValue?: number | null | string
} }
@ -22,8 +25,10 @@ const _vModel = useVModel(props, 'modelValue', emits)
const vModel = computed({ const vModel = computed({
get: () => _vModel.value, get: () => _vModel.value,
set: (value: string) => { set: (value) => {
if (value === '') { if (value === '') {
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null _vModel.value = null
} else { } else {
_vModel.value = value _vModel.value = value

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

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

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

@ -1,28 +1,53 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core' 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 { interface Props {
modelValue: string | null | undefined modelValue: string | null | undefined
} }
interface Emits { const { modelValue: value } = defineProps<Props>()
(event: 'update:modelValue', model: string): void
}
const props = defineProps<Props>() const emit = defineEmits(['update:modelValue'])
const emits = defineEmits<Emits>() const { t } = useI18n()
const { showNull } = useGlobal() const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj) const editEnabled = inject(EditModeInj)!
const column = inject(ColumnInj)!
const vModel = useVModel(props, 'modelValue', emits) // 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 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 validEmail = computed(() => vModel.value && validateEmail(vModel.value))
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() 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> </script>
<template> <template>
@ -30,7 +55,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
v-if="editEnabled" v-if="editEnabled"
:ref="focus" :ref="focus"
v-model="vModel" v-model="vModel"
class="outline-none text-sm px-2" class="w-full outline-none text-sm px-2"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop @keydown.down.stop
@keydown.left.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' import { EditModeInj, inject, useVModel } from '#imports'
interface Props { 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 { interface Emits {
@ -22,8 +25,10 @@ const _vModel = useVModel(props, 'modelValue', emits)
const vModel = computed({ const vModel = computed({
get: () => _vModel.value, get: () => _vModel.value,
set: (value: string) => { set: (value) => {
if (value === '') { if (value === '') {
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null _vModel.value = null
} else { } else {
_vModel.value = value _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' import { EditModeInj, inject, useVModel } from '#imports'
interface Props { 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 { interface Emits {
@ -22,8 +25,10 @@ const _vModel = useVModel(props, 'modelValue', emits)
const vModel = computed({ const vModel = computed({
get: () => _vModel.value, get: () => _vModel.value,
set: (value: string) => { set: (value) => {
if (value === '') { if (value === '') {
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null _vModel.value = null
} else { } else {
_vModel.value = value _vModel.value = value

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

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

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

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

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

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

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

@ -14,6 +14,11 @@ const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj) const editEnabled = inject(EditModeInj)
const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const readonly = inject(ReadonlyInj, ref(false)) const readonly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits) 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> <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> </template>

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

@ -10,7 +10,10 @@ const emits = defineEmits(['update:modelValue'])
const editEnabled = inject(EditModeInj) const editEnabled = inject(EditModeInj)
const rowHeight = inject(RowHeightInj) const rowHeight = inject(
RowHeightInj,
computed(() => undefined),
)
const { showNull } = useGlobal() const { showNull } = useGlobal()
@ -45,3 +48,9 @@ const focus: VNodeRef = (el) => (el as HTMLTextAreaElement)?.focus()
<span v-else>{{ vModel }}</span> <span v-else>{{ vModel }}</span>
</template> </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> <template>
<a-time-picker <a-time-picker
v-model:value="localState" v-model:value="localState"
autofocus
:show-time="true" :show-time="true"
:bordered="false" :bordered="false"
use12-hours use12-hours

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

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

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

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onKeyDown } from '@vueuse/core' import { onKeyDown } from '@vueuse/core'
import { useAttachmentCell } from './utils' 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()! const { selectedImage, visibleItems, downloadFile } = useAttachmentCell()!
@ -53,7 +53,11 @@ onClickOutside(carouselRef, () => {
<template v-if="selectedImage"> <template v-if="selectedImage">
<div class="overflow-hidden p-12 text-center relative"> <div class="overflow-hidden p-12 text-center relative">
<div class="text-white group absolute top-5 right-5"> <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>
<div <div

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Tooltip as ATooltip, Empty } from 'ant-design-vue' import { Tooltip as ATooltip, Empty } from 'ant-design-vue'
import type { AuditType } from 'nocodb-sdk' 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 { $api } = useNuxtApp()
const { project } = useProject() const { project } = storeToRefs(useProject())
const { t } = useI18n() const { t } = useI18n()
@ -28,8 +28,8 @@ async function loadAudits(page = currentPage, limit = currentLimit) {
isLoading = true isLoading = true
const { list, pageInfo } = await $api.project.auditList(project.value?.id, { const { list, pageInfo } = await $api.project.auditList(project.value?.id, {
offset: (limit * (page - 1)).toString(), offset: limit * (page - 1),
limit: limit.toString(), limit,
}) })
audits = list audits = list
@ -94,7 +94,7 @@ const columns = [
<a-button class="self-start" @click="loadAudits"> <a-button class="self-start" @click="loadAudits">
<!-- Reload --> <!-- Reload -->
<div class="flex items-center gap-2 text-gray-600 font-light"> <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') }} {{ $t('general.reload') }}
</div> </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 UIAcl from './UIAcl.vue'
import Erd from './Erd.vue' import Erd from './Erd.vue'
import { ClientType, DataSourcesSubTab } from '~/lib' import { ClientType, DataSourcesSubTab } from '~/lib'
import { useNuxtApp, useProject } from '#imports' import { storeToRefs, useNuxtApp, useProject } from '#imports'
interface Props { interface Props {
state: string state: string
@ -19,16 +19,24 @@ const props = defineProps<Props>()
const emits = defineEmits(['update:state', 'update:reload', 'awaken']) const emits = defineEmits(['update:state', 'update:reload', 'awaken'])
const vState = useVModel(props, 'state', emits) const vState = useVModel(props, 'state', emits)
const vReload = useVModel(props, 'reload', emits) const vReload = useVModel(props, 'reload', emits)
const { $api, $e } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { project, loadProject } = useProject() const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
let sources = $ref<BaseType[]>([]) let sources = $ref<BaseType[]>([])
let activeBaseId = $ref('') let activeBaseId = $ref('')
let metadiffbases = $ref<string[]>([]) let metadiffbases = $ref<string[]>([])
let clientType = $ref<ClientType>(ClientType.MYSQL) let clientType = $ref<ClientType>(ClientType.MYSQL)
let isReloading = $ref(false) let isReloading = $ref(false)
let forceAwakened = $ref(false) let forceAwakened = $ref(false)
async function loadBases() { async function loadBases() {
@ -38,8 +46,8 @@ async function loadBases() {
isReloading = true isReloading = true
vReload.value = true vReload.value = true
const baseList = await $api.base.list(project.value?.id) const baseList = await $api.base.list(project.value?.id)
if (baseList.bases.list && baseList.bases.list.length) { if (baseList.list && baseList.list.length) {
sources = baseList.bases.list sources = baseList.list
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@ -245,11 +253,11 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)" @click="baseAction(sources[0].id, DataSourcesSubTab.Metadata)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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> <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> </a-tooltip>
<MdiDatabaseSync v-else class="text-lg group-hover:text-accent" /> <GeneralIcon v-else icon="sync" class="group-hover:text-accent" />
Sync Metadata Sync Metadata
</div> </div>
</a-button> </a-button>
@ -258,7 +266,7 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.UIAcl)" @click="baseAction(sources[0].id, DataSourcesSubTab.UIAcl)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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 UI ACL
</div> </div>
</a-button> </a-button>
@ -267,7 +275,7 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.ERD)" @click="baseAction(sources[0].id, DataSourcesSubTab.ERD)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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 ERD
</div> </div>
</a-button> </a-button>
@ -277,7 +285,7 @@ watch(
@click="baseAction(sources[0].id, DataSourcesSubTab.Edit)" @click="baseAction(sources[0].id, DataSourcesSubTab.Edit)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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 Edit
</div> </div>
</a-button> </a-button>
@ -300,7 +308,7 @@ watch(
<template #item="{ element: base, index }"> <template #item="{ element: base, index }">
<div v-if="index !== 0" class="ds-table-row border-gray-200"> <div v-if="index !== 0" class="ds-table-row border-gray-200">
<div class="ds-table-col ds-table-name"> <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"> <div class="flex items-center gap-1">
<GeneralBaseLogo :base-type="base.type" /> <GeneralBaseLogo :base-type="base.type" />
{{ base.is_meta ? 'BASE' : base.alias }} <span class="text-gray-400 text-xs">({{ base.type }})</span> {{ 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)" @click="baseAction(base.id, DataSourcesSubTab.Metadata)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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> <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> </a-tooltip>
<MdiDatabaseSync v-else class="text-lg group-hover:text-accent" /> <GeneralIcon v-else icon="sync" class="group-hover:text-accent" />
Sync Metadata Sync Metadata
</div> </div>
</a-button> </a-button>
@ -327,13 +335,13 @@ watch(
@click="baseAction(base.id, DataSourcesSubTab.UIAcl)" @click="baseAction(base.id, DataSourcesSubTab.UIAcl)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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 UI ACL
</div> </div>
</a-button> </a-button>
<a-button class="nc-action-btn cursor-pointer outline-0" @click="baseAction(base.id, DataSourcesSubTab.ERD)"> <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"> <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 ERD
</div> </div>
</a-button> </a-button>
@ -343,13 +351,13 @@ watch(
@click="baseAction(base.id, DataSourcesSubTab.Edit)" @click="baseAction(base.id, DataSourcesSubTab.Edit)"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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 Edit
</div> </div>
</a-button> </a-button>
<a-button v-if="!base.is_meta" class="nc-action-btn cursor-pointer outline-0" @click="deleteBase(base)"> <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"> <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 Delete
</div> </div>
</a-button> </a-button>

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <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<{ const props = defineProps<{
baseId: string baseId: string
@ -9,7 +9,9 @@ const emit = defineEmits(['baseSynced'])
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { project, loadTables } = useProject() const projectStore = useProject()
const { loadTables } = projectStore
const { project } = storeToRefs(projectStore)
const { t } = useI18n() const { t } = useI18n()
@ -90,7 +92,7 @@ const columns = [
<!-- Reload --> <!-- Reload -->
<a-button v-e="['a:proj-meta:meta-data:reload']" class="self-start nc-btn-metasync-reload" @click="loadMetaDiff"> <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"> <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') }} {{ $t('general.reload') }}
</div> </div>
</a-button> </a-button>
@ -133,7 +135,7 @@ const columns = [
<div v-if="isDifferent"> <div v-if="isDifferent">
<a-button v-e="['a:proj-meta:meta-data:sync']" class="nc-btn-metasync-sync-now" type="primary" @click="syncMetaDiff"> <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"> <div class="flex items-center gap-2">
<MdiDatabaseSync /> <component :is="iconMap.databaseSync" />
{{ $t('activity.metaSync') }} {{ $t('activity.metaSync') }}
</div> </div>
</a-button> </a-button>

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

@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CheckboxChangeEvent } from 'ant-design-vue/es/checkbox/interface' 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 { 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()) 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 type { FunctionalComponent, SVGAttributes } from 'vue'
import DataSources from './DataSources.vue' import DataSources from './DataSources.vue'
import Misc from './Misc.vue' import Misc from './Misc.vue'
import { DataSourcesSubTab, useI18n, useNuxtApp, useUIPermission, useVModel, watch } from '#imports' import { DataSourcesSubTab, iconMap, 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'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
@ -54,7 +50,7 @@ const dataSourcesAwakened = ref(false)
const tabsInfo: TabGroup = { const tabsInfo: TabGroup = {
teamAndAuth: { teamAndAuth: {
title: t('title.teamAndAuth'), title: t('title.teamAndAuth'),
icon: TeamFillIcon, icon: iconMap.users,
subTabs: { subTabs: {
...(isUIAllowed('userMgmtTab') ...(isUIAllowed('userMgmtTab')
? { ? {
@ -82,7 +78,7 @@ const tabsInfo: TabGroup = {
dataSources: { dataSources: {
// Data Sources // Data Sources
title: 'Data Sources', title: 'Data Sources',
icon: MultipleTableIcon, icon: iconMap.datasource,
subTabs: { subTabs: {
dataSources: { dataSources: {
title: 'Data Sources', title: 'Data Sources',
@ -97,7 +93,7 @@ const tabsInfo: TabGroup = {
audit: { audit: {
// Audit // Audit
title: t('title.audit'), title: t('title.audit'),
icon: NotebookOutline, icon: iconMap.book,
subTabs: { subTabs: {
audit: { audit: {
// Audit // Audit
@ -112,7 +108,7 @@ const tabsInfo: TabGroup = {
projectSettings: { projectSettings: {
// Project Settings // Project Settings
title: 'Project Settings', title: 'Project Settings',
icon: FolderCog, icon: iconMap.settings,
subTabs: { subTabs: {
misc: { misc: {
// Misc // Misc
@ -174,7 +170,7 @@ watch(
data-testid="settings-modal-close-button" data-testid="settings-modal-close-button"
@click="vModel = false" @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> </a-button>
</div> </div>
@ -231,7 +227,7 @@ watch(
@click="vDataState = DataSourcesSubTab.New" @click="vDataState = DataSourcesSubTab.New"
> >
<div v-if="vDataState === ''" class="flex items-center gap-2 text-primary font-light"> <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 New
</div> </div>
</a-button> </a-button>
@ -242,7 +238,7 @@ watch(
@click="dataSourcesReload = true" @click="dataSourcesReload = true"
> >
<div class="flex items-center gap-2 text-gray-600 font-light"> <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') }} {{ $t('general.reload') }}
</div> </div>
</a-button> </a-button>

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

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

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

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

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

@ -15,11 +15,13 @@ import {
generateUniqueName, generateUniqueName,
getDefaultConnectionConfig, getDefaultConnectionConfig,
getTestDatabaseName, getTestDatabaseName,
iconMap,
nextTick, nextTick,
onMounted, onMounted,
projectTitleValidator, projectTitleValidator,
readFile, readFile,
ref, ref,
storeToRefs,
useApi, useApi,
useGlobal, useGlobal,
useI18n, useI18n,
@ -33,7 +35,9 @@ const emit = defineEmits(['baseCreated'])
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { project, loadProject } = useProject() const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
const useForm = Form.useForm const useForm = Form.useForm
@ -562,11 +566,15 @@ watch(
<a-input v-model:value="item.value" /> <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>
</div> </div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewParam"> <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-button>
</a-card> </a-card>
</a-form-item> </a-form-item>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -2,11 +2,11 @@
import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk' import type { LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk' import { UITypes } from 'nocodb-sdk'
import type { ERDConfig } from './utils' 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 props = defineProps<{ table?: TableType; baseId?: string }>()
const { tables: projectTables } = useProject() const { tables: projectTables } = storeToRefs(useProject())
const { metas, getMeta } = useMetas() const { metas, getMeta } = useMetas()
@ -40,8 +40,8 @@ const populateTables = async () => {
// if table is provided only get the table and its related tables // if table is provided only get the table and its related tables
localTables = projectTables.value.filter( localTables = projectTables.value.filter(
(t) => (t) =>
t.id === props.table.id || t.id === props.table?.id ||
props.table.columns?.find( props.table?.columns?.find(
(column) => (column) =>
column.uidt === UITypes.LinkToAnotherRecord && column.uidt === UITypes.LinkToAnotherRecord &&
(column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id === t.id, (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"> <script setup lang="ts">
import { iconMap } from '#imports'
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { t } = useI18n() const { t } = useI18n()
@ -14,7 +16,7 @@ const toggleDialog = inject(ToggleDialogInj, () => {})
> >
<div> <div>
<div class="flex items-center space-x-1"> <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>{{ t('title.teamAndSettings') }}</div>
</div> </div>
</div> </div>

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

@ -3,6 +3,10 @@ import { Icon } from '@iconify/vue'
import InfiniteLoading from 'v3-infinite-loading' import InfiniteLoading from 'v3-infinite-loading'
import { emojiIcons } from '#imports' import { emojiIcons } from '#imports'
const props = defineProps<{
showReset?: boolean
}>()
const emit = defineEmits(['selectIcon']) const emit = defineEmits(['selectIcon'])
let search = $ref('') let search = $ref('')
@ -23,13 +27,14 @@ const load = () => {
} }
} }
const selectIcon = (icon: string) => { const selectIcon = (icon?: string) => {
search = '' search = ''
emit('selectIcon', `emojione:${icon}`) emit('selectIcon', icon && `emojione:${icon}`)
} }
</script> </script>
<template> <template>
<div>
<div class="p-1 w-[280px] h-[280px] flex flex-col gap-1 justify-start nc-emoji" data-testid="nc-emoji-container"> <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> <div @click.stop>
<input <input
@ -49,6 +54,14 @@ const selectIcon = (icon: string) => {
<InfiniteLoading @infinite="load"><span /></InfiniteLoading> <InfiniteLoading @infinite="load"><span /></InfiniteLoading>
</div> </div>
</div> </div>
<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>
</div>
</div>
</template> </template>
<style scoped> <style scoped>

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

@ -1,11 +1,11 @@
<script lang="ts" setup> <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 showDrawer = ref(false)
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { project } = useProject() const { project } = storeToRefs(useProject())
const route = useRoute() 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)" 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" @click="showDrawer = true"
> >
<MdiCommentTextOutline class="mr-1" /> <component :is="iconMap.apiAndSupport" class="mr-1" />
<!-- APIs & Support --> <!-- APIs & Support -->
<div>{{ $t('title.APIsAndSupport') }}</div> <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> <template>
<a <a
v-e="['c:navbar:join-cloud']" v-e="['c:navbar:join-cloud']"
@ -5,7 +9,7 @@
href="https://docs.google.com/forms/d/e/1FAIpQLSfKLe8Rcrq0uo2_jM5W1kbVBbzDiQ3IvlP8Iov61FTekVAvzA/viewform?usp=pp_url" href="https://docs.google.com/forms/d/e/1FAIpQLSfKLe8Rcrq0uo2_jM5W1kbVBbzDiQ3IvlP8Iov61FTekVAvzA/viewform?usp=pp_url"
target="_blank" target="_blank"
> >
<PhCloudLightningDuotone class="mr-1" /> <component :is="iconMap.cloud" class="mr-1" />
Join NocoDB Cloud Join NocoDB Cloud
</a> </a>
</template> </template>

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

@ -1,18 +1,18 @@
<script lang="ts" setup> <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 { signOut, signedIn, user, currentVersion } = useGlobal()
const { isOpen } = useSidebar('nc-mini-sidebar', { isOpen: true }) const { isOpen } = useSidebar('nc-mini-sidebar', { isOpen: true })
const { project } = useProject() const { project } = storeToRefs(useProject())
const route = useRoute() const route = useRoute()
const email = computed(() => user.value?.email ?? '---') const email = computed(() => user.value?.email ?? '---')
const logout = () => { const logout = async () => {
signOut() await signOut()
navigateTo('/signin') navigateTo('/signin')
} }
</script> </script>
@ -42,7 +42,7 @@ const logout = () => {
<a-menu-item-group title="User Settings"> <a-menu-item-group title="User Settings">
<a-menu-item key="email" class="!rounded-t"> <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"> <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; &nbsp;
<span class="prose group-hover:text-black nc-user-menu-email">{{ email }}</span> <span class="prose group-hover:text-black nc-user-menu-email">{{ email }}</span>
</nuxt-link> </nuxt-link>
@ -52,7 +52,7 @@ const logout = () => {
<a-menu-item key="signout" class="!rounded-b"> <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"> <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"> <span class="prose font-semibold text-gray-500 group-hover:text-black nc-user-menu-signout">
{{ $t('general.signOut') }} {{ $t('general.signOut') }}
</span> </span>
@ -66,7 +66,7 @@ const logout = () => {
<div id="sidebar" ref="sidebar" class="text-white flex-auto flex flex-col items-center w-full"> <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"> <a-dropdown :trigger="['contextmenu']" placement="right" overlay-class-name="nc-dropdown">
<div :class="[route.name === 'index' ? 'active' : '']" class="nc-mini-sidebar-item" @click="navigateTo('/')"> <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> </div>
<template #overlay> <template #overlay>
@ -84,7 +84,7 @@ const logout = () => {
class="group flex items-center gap-2 py-2 hover:text-primary" class="group flex items-center gap-2 py-2 hover:text-primary"
@click="navigateTo('/project/create')" @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') }} {{ $t('activity.createProject') }}
</div> </div>
</a-menu-item> </a-menu-item>
@ -95,7 +95,7 @@ const logout = () => {
class="group flex items-center gap-2 py-2 hover:text-primary" class="group flex items-center gap-2 py-2 hover:text-primary"
@click="navigateTo('/project/create-external')" @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 v-html="$t('activity.createProjectExtended.extDB')" />
</div> </div>
</a-menu-item> </a-menu-item>
@ -112,7 +112,7 @@ const logout = () => {
class="nc-mini-sidebar-item" class="nc-mini-sidebar-item"
@click="navigateTo(`/${route.params.projectType}/${route.params.projectId}`)" @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> </div>
</a-tooltip> </a-tooltip>
</div> </div>

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

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

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { isDrawerOrModalExist, isMac, useNuxtApp, useRoute, useUIPermission } from '#imports' import { iconMap, isDrawerOrModalExist, isMac, useNuxtApp, useRoute, useUIPermission } from '#imports'
const route = useRoute() const route = useRoute()
@ -45,7 +45,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
</template> </template>
<a-button type="primary" class="!rounded-md mr-1" size="medium"> <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"> <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') }} {{ $t('activity.share') }}
</div> </div>
</a-button> </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> <script lang="ts" setup>
import { useI18n } from '#imports' import { iconMap, useI18n } from '#imports'
const { locale } = useI18n() 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"> <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 <div
v-e="['e:community:discourse']" v-e="['e:community:discourse']"
@ -29,11 +34,22 @@ const isZhLang = $computed(() => locale.value.startsWith('zh'))
<div class="discourse" /> <div class="discourse" />
</div> </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']" v-e="['e:community:book-demo']"
class="icon text-green-500" class="icon text-green-500"
@click="open('https://calendly.com/nocodb-meeting')" @click="open('https://calendly.com/nocodb-meeting')"

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <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() const { $e } = useNuxtApp()
@ -39,7 +39,7 @@ function openKeyboardShortcutDialog() {
to="https://docs.nocodb.com/" to="https://docs.nocodb.com/"
> >
<div class="ml-3 flex items-center text-sm"> <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> <span class="ml-3">{{ $t('labels.documentation') }}</span>
</div> </div>
</nuxt-link> </nuxt-link>
@ -55,7 +55,7 @@ function openKeyboardShortcutDialog() {
to="https://apis.nocodb.com/" to="https://apis.nocodb.com/"
> >
<div class="ml-3 flex items-center text-sm"> <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 --> <!-- todo: i18n -->
<span class="ml-3">API {{ $t('labels.documentation') }}</span> <span class="ml-3">API {{ $t('labels.documentation') }}</span>
</div> </div>
@ -72,7 +72,7 @@ function openKeyboardShortcutDialog() {
target="_blank" target="_blank"
> >
<div class="flex items-center text-sm"> <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"> <div v-if="isRtlLang">
<!-- us on Github --> <!-- us on Github -->
{{ $t('labels.community.starUs2') }} {{ $t('labels.community.starUs2') }}
@ -101,7 +101,7 @@ function openKeyboardShortcutDialog() {
target="_blank" target="_blank"
> >
<div class="flex items-center text-sm"> <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 --> <!-- Book a Free DEMO -->
<div> <div>
{{ $t('labels.community.bookDemo') }} {{ $t('labels.community.bookDemo') }}
@ -120,7 +120,7 @@ function openKeyboardShortcutDialog() {
target="_blank" target="_blank"
> >
<div class="flex items-center text-sm"> <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 --> <!-- Get your questions answered -->
<div> <div>
{{ $t('labels.community.getAnswered') }} {{ $t('labels.community.getAnswered') }}
@ -139,7 +139,7 @@ function openKeyboardShortcutDialog() {
target="_blank" target="_blank"
> >
<div class="flex items-center text-sm"> <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 --> <!-- Follow NocoDB -->
<div> <div>
{{ $t('labels.community.followNocodb') }} {{ $t('labels.community.followNocodb') }}
@ -176,15 +176,15 @@ function openKeyboardShortcutDialog() {
to="https://www.reddit.com/r/NocoDB/" to="https://www.reddit.com/r/NocoDB/"
> >
<div class="ml-3 flex items-center text-sm"> <div class="ml-3 flex items-center text-sm">
<LogosRedditIcon /> <component :is="iconMap.reddit" color="red" />
<span class="ml-4">/r/NocoDB/</span> <span class="ml-4">/r/NocoDB/</span>
</div> </div>
</nuxt-link> </nuxt-link>
</a-list-item> </a-list-item>
<a-list-item @click="openKeyboardShortcutDialog"> <a-list-item @click="openKeyboardShortcutDialog">
<div class="ml-3 flex items-center text-sm"> <div class="ml-3 flex items-center text-sm cursor-pointer">
<MdiKeyboard class="text-lg text-primary" /> <component :is="iconMap.keyboard" class="text-lg text-primary" />
<span class="ml-4">{{ $t('title.keyboardShortcut') }}</span> <span class="ml-4">{{ $t('title.keyboardShortcut') }}</span>
</div> </div>
</a-list-item> </a-list-item>

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

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Icon as IcIcon } from '@iconify/vue' import { Icon as IcIcon } from '@iconify/vue'
import type { TableType } from 'nocodb-sdk' import type { TableType } from 'nocodb-sdk'
import { iconMap } from '#imports'
const { meta: tableMeta } = defineProps<{ const { meta: tableMeta } = defineProps<{
meta: TableType meta: TableType
@ -15,8 +16,6 @@ const { meta: tableMeta } = defineProps<{
:icon="tableMeta.meta?.icon" :icon="tableMeta.meta?.icon"
/> />
<MdiEyeCircleOutline v-else-if="tableMeta?.type === 'view'" class="w-5" /> <component :is="iconMap.eye" v-else-if="tableMeta?.type === 'view'" class="w-5" />
<MdiTableLarge v-else class="w-5" /> <component :is="iconMap.table" v-else class="w-5" />
</template> </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, inject,
message, message,
ref, ref,
storeToRefs,
useCopy, useCopy,
useGlobal, useGlobal,
useI18n, useI18n,
@ -24,7 +25,7 @@ const emits = defineEmits(['update:modelValue'])
const { t } = useI18n() const { t } = useI18n()
const { project } = $(useProject()) const { project } = $(storeToRefs(useProject()))
const { appInfo, token } = $(useGlobal()) const { appInfo, token } = $(useGlobal())
@ -131,7 +132,7 @@ const onCopyToClipboard = async () => {
await copy(code) await copy(code)
// Copied to clipboard // Copied to clipboard
message.info(t('msg.info.copiedToClipboard')) message.info(t('msg.info.copiedToClipboard'))
} catch (e) { } catch (e: any) {
message.error(e.message) message.error(e.message)
} }
} }

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

@ -8,6 +8,7 @@ import {
IsFormInj, IsFormInj,
IsLockedInj, IsLockedInj,
IsPublicInj, IsPublicInj,
IsSurveyFormInj,
ReadonlyInj, ReadonlyInj,
computed, computed,
inject, inject,
@ -21,6 +22,7 @@ import {
isDuration, isDuration,
isEmail, isEmail,
isFloat, isFloat,
isGeoData,
isInt, isInt,
isJSON, isJSON,
isManualSaved, isManualSaved,
@ -38,6 +40,7 @@ import {
isYear, isYear,
provide, provide,
ref, ref,
storeToRefs,
toRef, toRef,
useDebounceFn, useDebounceFn,
useProject, useProject,
@ -64,7 +67,7 @@ const column = toRef(props, 'column')
const active = toRef(props, 'active', false) const active = toRef(props, 'active', false)
const readOnly = toRef(props, 'readOnly', undefined) const readOnly = toRef(props, 'readOnly', false)
provide(ColumnInj, column) provide(ColumnInj, column)
@ -82,9 +85,11 @@ const isPublic = inject(IsPublicInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false)) const isLocked = inject(IsLockedInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const { currentRow } = useSmartsheetRowStoreOrThrow() 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]) 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({ const vModel = computed({
get: () => props.modelValue, get: () => {
return props.modelValue
},
set: (val) => { set: (val) => {
if (val !== props.modelValue) { if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true 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 (isJSON(column.value)) return
if (currentRow.value.rowMeta.changed || currentRow.value.rowMeta.new) { if (currentRow.value.rowMeta.changed || currentRow.value.rowMeta.new) {
emit('save')
currentRow.value.rowMeta.changed = false currentRow.value.rowMeta.changed = false
} }
emit('navigate', dir) emit('navigate', dir)
@ -136,21 +142,33 @@ const isNumericField = computed(() => {
isDuration(column.value) 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> </script>
<template> <template>
<div <div
class="nc-cell w-full h-full" class="nc-cell w-full h-full relative"
:class="[ :class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, `nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ 'text-blue-600': isPrimary(column) && !props.virtual && !isForm }, { 'text-blue-600': isPrimary(column) && !props.virtual && !isForm },
{ 'nc-grid-numeric-cell': isGrid && !isForm && isNumericField }, { 'nc-grid-numeric-cell': isGrid && !isForm && isNumericField },
{ 'h-[40px]': !props.editEnabled && isForm && !isSurveyForm },
]" ]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="navigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
@contextmenu="onContextmenu"
> >
<template v-if="column"> <template v-if="column">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" /> <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" /> <LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" /> <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" /> <LazyCellSingleSelect v-else-if="isSingleSelect(column)" v-model="vModel" :row-index="props.rowIndex" />

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

@ -10,6 +10,7 @@ import {
computed, computed,
createEventHook, createEventHook,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
iconMap,
inject, inject,
message, message,
onClickOutside, onClickOutside,
@ -97,8 +98,12 @@ const submitted = ref(false)
const activeRow = ref('') const activeRow = ref('')
const editEnabled = ref<boolean[]>([])
const { t } = useI18n() const { t } = useI18n()
const { betaFeatureToggleState } = useBetaFeatureToggle()
const updateView = useDebounceFn( const updateView = useDebounceFn(
() => { () => {
if ((formViewData.value?.subheading?.length || 0) > 255) { if ((formViewData.value?.subheading?.length || 0) > 255) {
@ -281,6 +286,8 @@ function setFormData() {
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, required: !!c.required })) .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) systemFieldsIds.value = getSystemColumns(col).map((c) => c.fk_column_id)
hiddenColumns.value = col.filter( 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, () => { onClickOutside(draggableRef, () => {
activeRow.value = '' 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"> <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"> <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"> <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 --> <!-- Add new field to this table -->
<span class="color-transition group-hover:text-primary break-words"> <span class="color-transition group-hover:text-primary break-words">
{{ $t('activity.addField') }} {{ $t('activity.addField') }}
@ -586,7 +597,8 @@ watch(view, (nextView) => {
v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)" v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)"
class="absolute flex top-2 right-2" class="absolute flex top-2 right-2"
> >
<MdiEyeOffOutline <component
:is="iconMap.eyeSlash"
class="opacity-0 nc-field-remove-icon" class="opacity-0 nc-field-remove-icon"
data-testid="nc-field-remove-icon" data-testid="nc-field-remove-icon"
@click.stop="hideColumn(index)" @click.stop="hideColumn(index)"
@ -616,6 +628,30 @@ watch(view, (nextView) => {
/> />
</div> </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-form-item class="my-0 w-1/2 !mb-1">
<a-input <a-input
v-model:value="element.label" v-model:value="element.label"
@ -697,7 +733,10 @@ watch(view, (nextView) => {
:class="`nc-form-input-${element.title.replaceAll(' ', '')}`" :class="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`" :data-testid="`nc-form-input-${element.title.replaceAll(' ', '')}`"
:column="element" :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 @click.stop.prevent
/> />
</a-form-item> </a-form-item>

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

@ -17,6 +17,7 @@ import {
computed, computed,
createEventHook, createEventHook,
extractPkFromRow, extractPkFromRow,
iconMap,
inject, inject,
isImage, isImage,
isLTAR, isLTAR,
@ -262,7 +263,7 @@ watch(view, async (nextView) => {
</template> </template>
</a-carousel> </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> </template>
<div v-for="col in fieldsWithoutCover" :key="`record-${record.row.id}-${col.id}`"> <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> <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 { UITypes, isVirtualCol } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
@ -23,6 +23,7 @@ import {
createEventHook, createEventHook,
enumColor, enumColor,
extractPkFromRow, extractPkFromRow,
iconMap,
inject, inject,
isColumnRequiredAndNull, isColumnRequiredAndNull,
isDrawerOrModalExist, isDrawerOrModalExist,
@ -43,6 +44,7 @@ import {
useRoute, useRoute,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useUIPermission, useUIPermission,
useUndoRedo,
useViewData, useViewData,
watch, watch,
} from '#imports' } from '#imports'
@ -72,6 +74,8 @@ const hasEditPermission = $computed(() => isUIAllowed('xcDatatableEditable'))
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { addUndo, clone, defineViewScope } = useUndoRedo()
// todo: get from parent ( inject or use prop ) // todo: get from parent ( inject or use prop )
const isView = false const isView = false
@ -118,6 +122,7 @@ const {
selectedAllRecords, selectedAllRecords,
removeRowIfNew, removeRowIfNew,
navigateToSiblingRow, navigateToSiblingRow,
getExpandedRowIndex,
} = useViewData(meta, view, xWhere) } = useViewData(meta, view, xWhere)
const { getMeta } = useMetas() const { getMeta } = useMetas()
@ -453,6 +458,61 @@ async function clearCell(ctx: { row: number; col: number } | null, skipUpdate =
const columnObj = fields.value[ctx.col] const columnObj = fields.value[ctx.col]
if (isVirtualCol(columnObj)) { 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) await rowRefs[ctx.row]!.clearLTARCell(columnObj)
return return
} }
@ -761,7 +821,7 @@ const closeAddColumnDropdown = () => {
overlay-class-name="nc-dropdown-grid-add-column" overlay-class-name="nc-dropdown-grid-add-column"
> >
<div class="h-full w-[60px] flex items-center justify-center"> <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> </div>
<template #overlay> <template #overlay>
@ -787,13 +847,13 @@ const closeAddColumnDropdown = () => {
:data-testid="`grid-row-${rowIndex}`" :data-testid="`grid-row-${rowIndex}`"
> >
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${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 <div
v-if="!readOnly || !isLocked" v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500" class="nc-row-no text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }" :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>
<div <div
v-if="!readOnly" v-if="!readOnly"
@ -828,7 +888,8 @@ const closeAddColumnDropdown = () => {
v-else v-else
class="cursor-pointer flex items-center border-1 active:ring rounded p-1 hover:(bg-primary bg-opacity-10)" 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']" v-e="['c:row-expand']"
class="select-none transform hover:(text-accent scale-120) nc-row-expand" class="select-none transform hover:(text-accent scale-120) nc-row-expand"
@click="expandForm(row, state)" @click="expandForm(row, state)"
@ -899,7 +960,7 @@ const closeAddColumnDropdown = () => {
@click="addEmptyRow()" @click="addEmptyRow()"
> >
<div class="px-2 w-full flex items-center text-gray-500"> <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"> <span class="ml-1">
{{ $t('activity.addRow') }} {{ $t('activity.addRow') }}
@ -980,6 +1041,8 @@ const closeAddColumnDropdown = () => {
:row-id="routeQuery.rowId" :row-id="routeQuery.rowId"
:view="view" :view="view"
show-next-prev-icons show-next-prev-icons
:first-row="getExpandedRowIndex() === 0"
:last-row="getExpandedRowIndex() === data.length - 1"
@next="navigateToSiblingRow(NavigateDir.NEXT)" @next="navigateToSiblingRow(NavigateDir.NEXT)"
@prev="navigateToSiblingRow(NavigateDir.PREV)" @prev="navigateToSiblingRow(NavigateDir.PREV)"
/> />
@ -1056,7 +1119,7 @@ const closeAddColumnDropdown = () => {
position: sticky !important; position: sticky !important;
left: 80px; left: 80px;
z-index: 5; z-index: 5;
@apply border-r-1 border-r-gray-300; @apply border-r-2 border-r-gray-300;
} }
tbody td:nth-child(2) { tbody td:nth-child(2) {
@ -1064,7 +1127,7 @@ const closeAddColumnDropdown = () => {
left: 80px; left: 80px;
z-index: 4; z-index: 4;
background: white; 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, IsPublicInj,
MetaInj, MetaInj,
OpenNewRecordFormHookInj, OpenNewRecordFormHookInj,
extractPkFromRow,
iconMap,
inject, inject,
isImage, isImage,
isLTAR, isLTAR,
@ -20,6 +22,7 @@ import {
provide, provide,
useAttachment, useAttachment,
useKanbanViewStoreOrThrow, useKanbanViewStoreOrThrow,
useUndoRedo,
} from '#imports' } from '#imports'
import type { Row as RowType } from '~/lib' import type { Row as RowType } from '~/lib'
@ -75,12 +78,15 @@ const {
deleteStack, deleteStack,
shouldScrollToRight, shouldScrollToRight,
deleteRow, deleteRow,
moveHistory,
} = useKanbanViewStoreOrThrow() } = useKanbanViewStoreOrThrow()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { appInfo } = $(useGlobal()) const { appInfo } = $(useGlobal())
const { addUndo, defineViewScope } = useUndoRedo()
provide(IsFormInj, ref(false)) provide(IsFormInj, ref(false))
provide(IsGalleryInj, 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) { if (event.moved) {
const { oldIndex, newIndex } = event.moved const { oldIndex, newIndex } = event.moved
const { fk_grp_col_id, meta: stack_meta } = kanbanMetaData.value const { fk_grp_col_id, meta: stack_meta } = kanbanMetaData.value
@ -220,17 +226,52 @@ async function onMoveStack(event: any) {
await updateKanbanMeta({ await updateKanbanMeta({
meta: stackMetaObj, 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) { async function onMove(event: any, stackKey: string) {
if (event.added) { if (event.added) {
const ele = event.added.element 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 ele.row[groupingField.value] = stackKey
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1) countByStack.value.set(stackKey, countByStack.value.get(stackKey)! + 1)
await updateOrSaveRow(ele) await updateOrSaveRow(ele)
} else if (event.removed) { } else if (event.removed) {
countByStack.value.set(stackKey, countByStack.value.get(stackKey)! - 1) 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() const newRow = await addEmptyRow()
// preset the grouping field value // preset the grouping field value
newRow.row = { newRow.row = {
[groupingField.value]: selectedStackTitle.value, [groupingField.value]: selectedStackTitle.value === '' ? null : selectedStackTitle.value,
} }
// increase total count by 1 // increase total count by 1
countByStack.value.set(null, countByStack.value.get(null)! + 1) countByStack.value.set(null, countByStack.value.get(null)! + 1)
@ -366,7 +407,7 @@ watch(view, async (nextView) => {
> >
<LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText> <LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText>
<span v-if="!isLocked" class="w-full flex w-[15px]"> <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> </span>
</div> </div>
<template v-if="!isLocked" #overlay> <template v-if="!isLocked" #overlay>
@ -382,13 +423,13 @@ watch(view, async (nextView) => {
" "
> >
<div class="py-2 flex gap-2 items-center"> <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') }} {{ $t('activity.addNewRecord') }}
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item v-e="['c:kanban:collapse-stack']" @click="handleCollapseStack(stackIdx)"> <a-menu-item v-e="['c:kanban:collapse-stack']" @click="handleCollapseStack(stackIdx)">
<div class="py-2 flex gap-2 items-center"> <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') }} {{ $t('activity.kanban.collapseStack') }}
</div> </div>
</a-menu-item> </a-menu-item>
@ -398,7 +439,7 @@ watch(view, async (nextView) => {
@click="handleDeleteStackClick(stack.title, stackIdx)" @click="handleDeleteStackClick(stack.title, stackIdx)"
> >
<div class="py-2 flex gap-2 items-center"> <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') }} {{ $t('activity.kanban.deleteStack') }}
</div> </div>
</a-menu-item> </a-menu-item>
@ -470,7 +511,7 @@ watch(view, async (nextView) => {
</template> </template>
</a-carousel> </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> </template>
<div <div
v-for="col in fieldsWithoutCover" v-for="col in fieldsWithoutCover"
@ -524,7 +565,8 @@ watch(view, async (nextView) => {
<a-layout-footer> <a-layout-footer>
<div v-if="formattedData.get(stack.title) && countByStack.get(stack.title) >= 0" class="mt-5 text-center"> <div v-if="formattedData.get(stack.title) && countByStack.get(stack.title) >= 0" class="mt-5 text-center">
<!-- Stack Title --> <!-- Stack Title -->
<mdi-plus <component
:is="iconMap.plus"
v-if="!isPublic && !isLocked" v-if="!isPublic && !isLocked"
class="text-pint-500 text-lg text-primary cursor-pointer" class="text-pint-500 text-lg text-primary cursor-pointer"
@click=" @click="
@ -570,7 +612,7 @@ watch(view, async (nextView) => {
:class="{ capitalize: stack.title === null }" :class="{ capitalize: stack.title === null }"
> >
<LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText> <LazyGeneralTruncateText>{{ stack.title ?? 'uncategorized' }}</LazyGeneralTruncateText>
<mdi-menu-down class="text-grey text-lg" /> <component :is="iconMap.arrowDown" class="text-grey text-lg" />
</div> </div>
<!-- Record Count --> <!-- Record Count -->
{{ formattedData.get(stack.title).length }} / {{ countByStack.get(stack.title) }} {{ 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 class="shadow !rounded !py-0" @click="contextMenu = false">
<a-menu-item v-if="contextMenuTarget" @click="expandForm(contextMenuTarget)"> <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"> <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 --> <!-- Expand Record -->
{{ $t('activity.expandRecord') }} {{ $t('activity.expandRecord') }}
</div> </div>
@ -594,7 +636,7 @@ watch(view, async (nextView) => {
<a-divider class="!m-0 !p-0" /> <a-divider class="!m-0 !p-0" />
<a-menu-item v-if="contextMenuTarget" @click="deleteRow(contextMenuTarget)"> <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"> <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 --> <!-- Delete Record -->
{{ $t('activity.deleteRecord') }} {{ $t('activity.deleteRecord') }}
</div> </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"> <script setup lang="ts">
import { ChangePageInj, PaginationDataInj, computed, inject } from '#imports' import { ChangePageInj, PaginationDataInj, computed, iconMap, inject } from '#imports'
const paginatedData = inject(PaginationDataInj)! const paginatedData = inject(PaginationDataInj)!
@ -39,7 +39,7 @@ const page = computed({
<span class="text-xs" style="white-space: nowrap"> Change page:</span> <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)"> <a-input :value="page" size="small" class="ml-1 !text-xs" type="number" @keydown.enter="changePage(page)">
<template #suffix> <template #suffix>
<MdiKeyboardReturn class="mt-1" @click="changePage(page)" /> <component :is="iconMap.returnKey" class="mt-1" @click="changePage(page)" />
</template> </template>
</a-input> </a-input>
</div> </div>

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

@ -22,7 +22,7 @@ const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow() 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 // on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => { watch(isNew, async (nextVal, prevVal) => {
@ -49,6 +49,7 @@ provide(ReloadRowDataHookInj, reloadHook)
defineExpose({ defineExpose({
syncLTARRefs, syncLTARRefs,
clearLTARCell, clearLTARCell,
addLTARRef,
}) })
</script> </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"> <script setup lang="ts">
import { IsPublicInj, inject, ref, useSharedView, useSidebar, useSmartsheetStoreOrThrow, useUIPermission } from '#imports' 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 isPublic = inject(IsPublicInj, ref(false))
const { isMobileMode } = useGlobal()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { isOpen } = useSidebar('nc-right-sidebar') const { isOpen } = useSidebar('nc-right-sidebar')
@ -14,11 +16,12 @@ const { allowCSVDownload } = useSharedView()
<template> <template>
<div <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" style="z-index: 7"
> >
<LazySmartsheetToolbarViewActions <LazySmartsheetToolbarViewActions
v-if="(isGrid || isGallery || isKanban) && !isPublic && isUIAllowed('dataInsert')" v-if="(isGrid || isGallery || isKanban || isMap) && !isPublic && isUIAllowed('dataInsert')"
:show-system-fields="false" :show-system-fields="false"
class="ml-1" class="ml-1"
/> />
@ -29,18 +32,22 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" /> <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" /> <LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
<LazySmartsheetToolbarRowHeight v-if="isGrid" /> <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)" /> <LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<div class="flex-1" /> <div v-if="!isMobileMode" class="flex-1" />
<LazySmartsheetToolbarReload v-if="!isPublic && !isForm" /> <LazySmartsheetToolbarReload v-if="!isPublic && !isForm" />
@ -49,7 +56,7 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery || isKanban) && !isPublic" class="shrink mx-2" /> <LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery || isKanban) && !isPublic" class="shrink mx-2" />
<template v-if="!isOpen && !isPublic"> <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" /> <LazySmartsheetSidebarToolbarToggleDrawer class="mr-2" />
</div> </div>
</template> </template>
@ -64,4 +71,7 @@ const { allowCSVDownload } = useSharedView()
.nc-table-toolbar { .nc-table-toolbar {
border-color: #f0f0f0 !important; border-color: #f0f0f0 !important;
} }
.nc-table-toolbar-mobile {
@apply flex-wrap h-auto py-2;
}
</style> </style>

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

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

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

@ -41,6 +41,8 @@ const { $e } = useNuxtApp()
const { appInfo } = useGlobal() const { appInfo } = useGlobal()
const { betaFeatureToggleState } = useBetaFeatureToggle()
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const isForm = inject(IsFormInj, ref(false)) 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 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>(() => { const uiTypesOptions = computed<typeof uiTypes>(() => {
return [ 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) ...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
? [ ? [
{ {
@ -80,8 +86,12 @@ const reloadMetaAndData = async () => {
} }
} }
const saving = ref(false)
async function onSubmit() { async function onSubmit() {
saving.value = true
const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition) const saved = await addOrUpdate(reloadMetaAndData, props.columnPosition)
saving.value = false
if (!saved) return if (!saved) return
@ -178,6 +188,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
<LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" /> <LazySmartsheetColumnQrCodeOptions v-if="formState.uidt === UITypes.QrCode" v-model="formState" />
<LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" /> <LazySmartsheetColumnBarcodeOptions v-if="formState.uidt === UITypes.Barcode" v-model="formState" />
<LazySmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" v-model:value="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" /> <LazySmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" v-model:value="formState" />
<LazySmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" 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" /> <LazySmartsheetColumnCheckboxOptions v-if="formState.uidt === UITypes.Checkbox" v-model:value="formState" />
@ -223,7 +234,10 @@ useEventListener('keydown', (e: KeyboardEvent) => {
v-model:value="formState" 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> </div>
</Transition> </Transition>
@ -234,7 +248,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
{{ $t('general.cancel') }} {{ $t('general.cancel') }}
</a-button> </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 --> <!-- Save -->
{{ $t('general.save') }} {{ $t('general.save') }}
</a-button> </a-button>

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

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

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ModelTypes, MssqlUi, SqliteUi } from 'nocodb-sdk' 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 MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline' import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
@ -10,13 +10,15 @@ const props = defineProps<{
const emit = defineEmits(['update:value']) const emit = defineEmits(['update:value'])
const { appInfo } = $(useGlobal())
const vModel = useVModel(props, 'value', emit) const vModel = useVModel(props, 'value', emit)
const meta = $(inject(MetaInj, ref())) const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, sqlUi } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, onDataTypeChange, sqlUi } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject()) const { tables } = $(storeToRefs(useProject()))
setAdditionalValidations({ setAdditionalValidations({
childId: [{ required: true, message: 'Required' }], 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.parentTable) vModel.value.parentTable = vModel.value.rtn || ''
if (!vModel.value.parentColumn) vModel.value.parentColumn = vModel.value.rcn || '' 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.onUpdate) vModel.value.onUpdate = onUpdateDeleteOptions[0]
if (!vModel.value.onDelete) vModel.value.onDelete = 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 if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name
const advancedOptions = $(ref(false)) const advancedOptions = $(ref(false))
@ -55,7 +57,7 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
<div class="border-2 p-6"> <div class="border-2 p-6">
<a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type"> <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-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 value="mm">Many To Many</a-radio>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>
@ -127,7 +129,9 @@ const filterOption = (value: string, option: { key: string }) => option.key.toLo
<div class="flex flex-row"> <div class="flex flex-row">
<a-form-item> <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> </a-form-item>
</div> </div>
</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 type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn } from 'nocodb-sdk' import { UITypes, isSystemColumn } from 'nocodb-sdk'
import { getRelationName } from './utils' 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<{ const props = defineProps<{
value: any value: any
@ -17,7 +17,7 @@ const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject()) const { tables } = $(storeToRefs(useProject()))
const { metas } = $(useMetas()) 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 type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { getRelationName } from './utils' 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<{ const props = defineProps<{
value: any value: any
@ -17,7 +17,7 @@ const meta = $(inject(MetaInj, ref()))
const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow() const { setAdditionalValidations, validateInfos, onDataTypeChange, isEdit } = useColumnCreateStoreOrThrow()
const { tables } = $(useProject()) const { tables } = $(storeToRefs(useProject()))
const { metas } = $(useMetas()) const { metas } = $(useMetas())

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

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

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

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

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

@ -1,14 +1,108 @@
<script setup lang="ts"> <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() useExpandedFormStoreOrThrow()
const commentsWrapperEl = ref<HTMLDivElement>() const commentsWrapperEl = ref<HTMLDivElement>()
await loadCommentsAndLogs() 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( watch(
commentsAndLogs, commentsAndLogs,
@ -25,11 +119,25 @@ watch(
<template> <template>
<div class="h-full flex flex-col w-full bg-[#eceff1] p-2"> <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"> <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" /> <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> <template v-else>
<div v-for="log of commentsAndLogs" :key="log.id" class="flex gap-1 text-xs"> <div v-for="(log, idx) of commentsAndLogs" :key="log.id">
<MdiAccountCircle class="row-span-2" :class="isYou(log.user) ? 'text-pink-300' : 'text-blue-300 '" /> <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"> <div class="flex-1">
<p class="mb-1 caption edited-text text-[10px] text-gray-500"> <p class="mb-1 caption edited-text text-[10px] text-gray-500">
@ -37,13 +145,22 @@ watch(
{{ log.op_type === 'COMMENT' ? 'commented' : log.op_sub_type === 'INSERT' ? 'created' : 'edited' }} {{ log.op_type === 'COMMENT' ? 'commented' : log.op_sub_type === 'INSERT' ? 'created' : 'edited' }}
</p> </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 <p
v-if="log.op_type === 'COMMENT'" v-else
class="block caption my-2 nc-chip w-full min-h-20px p-2 rounded" class="block caption my-2 nc-chip w-full min-h-20px p-2 rounded"
:style="{ backgroundColor: enumColor.light[2] }" :style="{ backgroundColor: enumColor.light[2] }"
> >
{{ log.description }} {{ log.description }}
</p> </p>
</div>
<p v-else v-dompurify-html="log.details" class="caption my-3" style="word-break: break-all" /> <p v-else v-dompurify-html="log.details" class="caption my-3" style="word-break: break-all" />
@ -52,6 +169,23 @@ watch(
</p> </p>
</div> </div>
</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> </template>
</div> </div>
@ -62,7 +196,6 @@ watch(
<!-- Comments only --> <!-- Comments only -->
<a-checkbox v-model:checked="commentsOnly" v-e="['c:row-expand:comment-only']" @change="loadCommentsAndLogs"> <a-checkbox v-model:checked="commentsOnly" v-e="['c:row-expand:comment-only']" @change="loadCommentsAndLogs">
{{ $t('labels.commentsOnly') }} {{ $t('labels.commentsOnly') }}
<span class="text-[11px] text-gray-500" /> <span class="text-[11px] text-gray-500" />
</a-checkbox> </a-checkbox>
</div> </div>
@ -72,19 +205,19 @@ watch(
v-model:value="comment" v-model:value="comment"
class="!text-xs nc-comment-box" class="!text-xs nc-comment-box"
ghost ghost
:class="{ focus: showborder }" :class="{ focus: showBorder }"
@focusin="showborder = true" @focusin="showBorder = true"
@focusout="showborder = false" @focusout="showBorder = false"
@keyup.enter.prevent="saveComment" @keyup.enter.prevent="saveComment"
> >
<template #addonBefore> <template #addonBefore>
<div class="flex items-center"> <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> </div>
</template> </template>
<template #suffix> <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> </template>
</a-input> </a-input>
</div> </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 type { ViewType } from 'nocodb-sdk'
import { import {
ReloadRowDataHookInj, ReloadRowDataHookInj,
iconMap,
isMac, isMac,
useExpandedFormStoreOrThrow, useExpandedFormStoreOrThrow,
useSmartsheetRowStoreOrThrow, useSmartsheetRowStoreOrThrow,
@ -18,7 +19,7 @@ const route = useRoute()
const { meta, isSqlView } = useSmartsheetStoreOrThrow() 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() const { isNew, syncLTARRefs, state } = useSmartsheetRowStoreOrThrow()
@ -26,8 +27,6 @@ const { isUIAllowed } = useUIPermission()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook()) const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const saveRowAndStay = ref(0)
const save = async () => { const save = async () => {
if (isNew.value) { if (isNew.value) {
const data = await _save(state.value) const data = await _save(state.value)
@ -103,23 +102,13 @@ const onConfirmDeleteRowClick = async () => {
</h5> </h5>
<div class="flex-1" /> <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"> <a-tooltip placement="bottom">
<template #title> <template #title>
<!-- todo: i18n --> <!-- todo: i18n -->
<div class="text-center w-full">Copy record URL</div> <div class="text-center w-full">Copy record URL</div>
</template> </template>
<mdi-link <component
:is="iconMap.link"
v-if="!isNew" v-if="!isNew"
class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 nc-copy-row-url min-w-4" class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 nc-copy-row-url min-w-4"
@click="copyRecordUrl" @click="copyRecordUrl"
@ -131,7 +120,8 @@ const onConfirmDeleteRowClick = async () => {
<template #title> <template #title>
<div class="text-center w-full">{{ $t('activity.toggleCommentsDraw') }}</div> <div class="text-center w-full">{{ $t('activity.toggleCommentsDraw') }}</div>
</template> </template>
<MdiCommentTextOutline <component
:is="iconMap.comment"
v-if="isUIAllowed('rowComments') && !isNew" v-if="isUIAllowed('rowComments') && !isNew"
v-e="['c:row-expand:comment-toggle']" 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" 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>
<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"> <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> <template #overlay>
<a-menu class="nc-expand-form-save-dropdown-menu"> <a-menu class="nc-expand-form-save-dropdown-menu">
<a-menu-item key="0" class="!py-2 flex gap-2" @click="saveRowAndStay = 0"> <a-menu-item key="0" class="!py-2 flex gap-2" @click="saveRowAndStay = 0">
<div class="flex items-center"> <div class="flex items-center">
<MdiContentSave class="mr-1" /> <component :is="iconMap.contentSaveExit" class="mr-1" />
{{ $t('activity.saveAndExit') }} {{ $t('activity.saveAndExit') }}
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item key="1" class="!py-2 flex gap-2 items-center" @click="saveRowAndStay = 1"> <a-menu-item key="1" class="!py-2 flex gap-2 items-center" @click="saveRowAndStay = 1">
<div class="flex items-center"> <div class="flex items-center">
<MdiContentSaveEdit class="mr-1" /> <component :is="iconMap.contentSaveStay" class="mr-1" />
{{ $t('activity.saveAndStay') }} {{ $t('activity.saveAndStay') }}
</div> </div>
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
</template> </template>
<div v-if="saveRowAndStay === 0" class="flex items-center"> <div v-if="saveRowAndStay === 0" class="flex items-center">
<MdiContentSave class="mr-1" /> <component :is="iconMap.contentSaveExit" class="mr-1" />
{{ $t('activity.saveAndExit') }} {{ $t('activity.saveAndExit') }}
</div> </div>
<div v-if="saveRowAndStay === 1" class="flex items-center"> <div v-if="saveRowAndStay === 1" class="flex items-center">
<MdiContentSaveEdit class="mr-1" /> <component :is="iconMap.contentSaveStay" class="mr-1" />
{{ $t('activity.saveAndStay') }} {{ $t('activity.saveAndStay') }}
</div> </div>
</a-dropdown-button> </a-dropdown-button>
<a-tooltip placement="bottom"> <a-dropdown>
<!-- Close --> <component :is="iconMap.threeDotVertical" class="nc-icon-transition" />
<template #title> <template #overlay>
<div class="text-center w-full">{{ $t('general.close') }}</div> <a-menu>
</template> <a-menu-item v-if="!isNew" @click="loadRow">
<MdiCloseCircleOutline <div v-e="['c:row-expand:reload']" class="py-2 flex gap-2 items-center">
class="nc-icon-transition cursor-pointer select-none nc-close-form text-gray-500 mx-1 min-w-4" <component :is="iconMap.reload" class="nc-icon-transition cursor-pointer select-none text-gray-500 mx-1 min-w-4" />
@click="emit('cancel')" {{ $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"
/> />
</a-tooltip> {{ $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>
</a-dropdown>
<a-modal v-model:visible="showDeleteRowModal" title="Delete row?" @ok="onConfirmDeleteRowClick"> <a-modal v-model:visible="showDeleteRowModal" title="Delete row?" @ok="onConfirmDeleteRowClick">
<p>Are you sure you want to delete this row?</p> <p>Are you sure you want to delete this row?</p>
</a-modal> </a-modal>

149
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 { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { import {
CellClickHookInj,
FieldsInj, FieldsInj,
IsFormInj, IsFormInj,
IsKanbanInj, IsKanbanInj,
@ -10,6 +11,7 @@ import {
ReloadRowDataHookInj, ReloadRowDataHookInj,
computedInject, computedInject,
createEventHook, createEventHook,
iconMap,
inject, inject,
message, message,
provide, provide,
@ -21,6 +23,7 @@ import {
useVModel, useVModel,
watch, watch,
} from '#imports' } from '#imports'
import { useActiveKeyupListener } from '~/composables/useSelectedCellKeyupListener'
import type { Row } from '~/lib' import type { Row } from '~/lib'
interface Props { interface Props {
@ -33,12 +36,16 @@ interface Props {
rowId?: string rowId?: string
view?: ViewType view?: ViewType
showNextPrevIcons?: boolean showNextPrevIcons?: boolean
firstRow?: boolean
lastRow?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev']) const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev'])
const key = ref(0)
const { t } = useI18n() const { t } = useI18n()
const row = ref(props.row) const row = ref(props.row)
@ -49,6 +56,9 @@ const meta = toRef(props, 'meta')
const router = useRouter() const router = useRouter()
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, null)
const fields = computedInject(FieldsInj, (_fields) => { const fields = computedInject(FieldsInj, (_fields) => {
if (props.useMetaFields) { if (props.useMetaFields) {
return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col)) return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col))
@ -60,7 +70,16 @@ const isKanban = inject(IsKanbanInj, ref(false))
provide(MetaInj, meta) 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) const duplicatingRowInProgress = ref(false)
@ -122,6 +141,25 @@ const onDuplicateRow = () => {
}, 500) }, 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()) const reloadParentRowHook = inject(ReloadRowDataHookInj, createEventHook())
// override reload trigger and use it to reload grid and the form itself // override reload trigger and use it to reload grid and the form itself
@ -132,7 +170,6 @@ reloadHook.on(() => {
if (isNew.value) return if (isNew.value) return
loadRow() loadRow()
}) })
provide(ReloadRowDataHookInj, reloadHook) provide(ReloadRowDataHookInj, reloadHook)
if (isKanban.value) { if (isKanban.value) {
@ -147,10 +184,94 @@ if (isKanban.value) {
const cellWrapperEl = ref<HTMLElement>() const cellWrapperEl = ref<HTMLElement>()
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => (cellWrapperEl.value?.querySelector('input,select,textarea') as HTMLInputElement)?.focus())
;(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>
<script lang="ts"> <script lang="ts">
@ -171,21 +292,25 @@ export default {
> >
<SmartsheetExpandedFormHeader :view="props.view" @cancel="onClose" @duplicate-row="onDuplicateRow" /> <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 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"> <div class="flex-1 overflow-auto scrollbar-thin-dull nc-form-fields-container relative">
<template v-if="props.showNextPrevIcons"> <template v-if="props.showNextPrevIcons">
<a-tooltip placement="bottom"> <a-tooltip v-if="!props.firstRow" placement="bottom">
<template #title> <template #title>
{{ $t('labels.nextRow') }} {{ $t('labels.prevRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '←']" />
</template> </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>
<a-tooltip placement="bottom">
<a-tooltip v-if="!props.lastRow" placement="bottom">
<template #title> <template #title>
{{ $t('labels.prevRow') }} {{ $t('labels.nextRow') }}
<GeneralShortcutLabel class="justify-center" :keys="['Alt', '→']" />
</template> </template>
<MdiChevronLeft class="cursor-pointer nc-prev-arrow" @click="$emit('prev')" /> <GeneralIcon icon="chevronRight" class="cursor-pointer nc-next-arrow" @click="onNext" />
</a-tooltip> </a-tooltip>
</template> </template>
<div class="w-[500px] mx-auto"> <div class="w-[500px] mx-auto">
@ -259,7 +384,7 @@ export default {
.nc-prev-arrow, .nc-prev-arrow,
.nc-next-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 { .nc-prev-arrow {

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

Loading…
Cancel
Save