Browse Source

Merge pull request #7707 from nocodb/develop

pull/7708/head 0.204.1
github-actions[bot] 7 months ago committed by GitHub
parent
commit
9b5ac3357d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .github/ISSUE_TEMPLATE/--bug-report.yaml
  2. 2
      .github/ISSUE_TEMPLATE/--feature-request.yaml
  3. 2
      .github/uffizzi/docker-compose.uffizzi.yml
  4. 4
      .github/workflows/ci-cd.yml
  5. 2
      .github/workflows/playwright-test-workflow.yml
  6. 2
      .github/workflows/pre-build-for-playwright.yml
  7. 2
      .github/workflows/publish-blog.yml
  8. 2
      .github/workflows/publish-dev-docs.yml
  9. 2
      .github/workflows/publish-docs.yml
  10. 2
      .github/workflows/publish-noco-i18n.yml
  11. 2
      .github/workflows/publish-prev-docs.yml
  12. 2
      .github/workflows/release-docker.yml
  13. 2
      .github/workflows/release-draft.yml
  14. 2
      .github/workflows/release-executables.yml
  15. 4
      .github/workflows/release-npm.yml
  16. 2
      .github/workflows/release-timely-executables.yml
  17. 2
      .github/workflows/update-sdk-path.yml
  18. 2
      .npmrc
  19. 4
      README.md
  20. 2
      docker-compose/mysql/docker-compose.yml
  21. 2
      docker-compose/nginx-proxy-manager/docker-compose.yml
  22. 551
      docker-compose/setup-script/noco.sh
  23. 1
      markdown/readme/languages/README.md
  24. 283
      markdown/readme/languages/ukrainian.md
  25. 2
      packages/nc-gui/.eslintrc.js
  26. 66
      packages/nc-gui/app.vue
  27. 2
      packages/nc-gui/assets/css/typesense-docsearch.css
  28. 20
      packages/nc-gui/assets/img/brand/nocodb-logo.svg
  29. BIN
      packages/nc-gui/assets/img/form-banner-left.png
  30. BIN
      packages/nc-gui/assets/img/form-banner-right.png
  31. BIN
      packages/nc-gui/assets/img/placeholder/api-tokens.png
  32. BIN
      packages/nc-gui/assets/img/placeholder/invite-team.png
  33. BIN
      packages/nc-gui/assets/img/placeholder/link-records.png
  34. BIN
      packages/nc-gui/assets/img/placeholder/multi-field-editor.png
  35. BIN
      packages/nc-gui/assets/img/placeholder/table.png
  36. BIN
      packages/nc-gui/assets/img/placeholder/webhooks.png
  37. 6
      packages/nc-gui/assets/js/typesense-docsearch.js
  38. 4
      packages/nc-gui/assets/nc-icons/arrow-down.svg
  39. 4
      packages/nc-gui/assets/nc-icons/arrow-up.svg
  40. 3
      packages/nc-gui/assets/nc-icons/chevron-down.svg
  41. 11
      packages/nc-gui/assets/nc-icons/copy.svg
  42. 6
      packages/nc-gui/assets/nc-icons/download.svg
  43. 6
      packages/nc-gui/assets/nc-icons/duplicate.svg
  44. 11
      packages/nc-gui/assets/nc-icons/edit.svg
  45. 2
      packages/nc-gui/assets/nc-icons/eye.svg
  46. 4
      packages/nc-gui/assets/nc-icons/paste.svg
  47. 10
      packages/nc-gui/assets/nc-icons/pencil.svg
  48. 3
      packages/nc-gui/assets/nc-icons/phone-call.svg
  49. 9
      packages/nc-gui/assets/nc-icons/project-gray.svg
  50. 4
      packages/nc-gui/assets/nc-icons/rename.svg
  51. 6
      packages/nc-gui/assets/nc-icons/trash.svg
  52. 6
      packages/nc-gui/assets/nc-icons/upload.svg
  53. 4
      packages/nc-gui/assets/nc-icons/user.svg
  54. 36
      packages/nc-gui/assets/style.scss
  55. 13
      packages/nc-gui/components.d.ts
  56. 61
      packages/nc-gui/components/account/Token.vue
  57. 39
      packages/nc-gui/components/account/UserList.vue
  58. 3
      packages/nc-gui/components/cell/Checkbox.vue
  59. 29
      packages/nc-gui/components/cell/Currency.vue
  60. 8
      packages/nc-gui/components/cell/DatePicker.vue
  61. 7
      packages/nc-gui/components/cell/DateTimePicker.vue
  62. 13
      packages/nc-gui/components/cell/Decimal.vue
  63. 18
      packages/nc-gui/components/cell/Duration.vue
  64. 23
      packages/nc-gui/components/cell/Email.vue
  65. 6
      packages/nc-gui/components/cell/Float.vue
  66. 9
      packages/nc-gui/components/cell/GeoData.vue
  67. 13
      packages/nc-gui/components/cell/Integer.vue
  68. 11
      packages/nc-gui/components/cell/Json.vue
  69. 7
      packages/nc-gui/components/cell/MultiSelect.vue
  70. 18
      packages/nc-gui/components/cell/Percent.vue
  71. 26
      packages/nc-gui/components/cell/PhoneNumber.vue
  72. 6
      packages/nc-gui/components/cell/Rating.vue
  73. 4
      packages/nc-gui/components/cell/ReadOnlyDateTimePicker.vue
  74. 6
      packages/nc-gui/components/cell/ReadOnlyUser.vue
  75. 102
      packages/nc-gui/components/cell/RichText.vue
  76. 9
      packages/nc-gui/components/cell/SingleSelect.vue
  77. 11
      packages/nc-gui/components/cell/Text.vue
  78. 192
      packages/nc-gui/components/cell/TextArea.vue
  79. 8
      packages/nc-gui/components/cell/TimePicker.vue
  80. 30
      packages/nc-gui/components/cell/Url.vue
  81. 44
      packages/nc-gui/components/cell/User.vue
  82. 7
      packages/nc-gui/components/cell/YearPicker.vue
  83. 7
      packages/nc-gui/components/cell/attachment/Modal.vue
  84. 167
      packages/nc-gui/components/cell/attachment/utils.ts
  85. 61
      packages/nc-gui/components/cmd-footer/index.vue
  86. 37
      packages/nc-gui/components/cmd-j/index.vue
  87. 162
      packages/nc-gui/components/cmd-k/command-score.ts
  88. 622
      packages/nc-gui/components/cmd-k/index.vue
  89. 315
      packages/nc-gui/components/cmd-l/index.vue
  90. 3
      packages/nc-gui/components/dashboard/Sidebar.vue
  91. 3
      packages/nc-gui/components/dashboard/Sidebar/BeforeUserInfo.vue
  92. 27
      packages/nc-gui/components/dashboard/Sidebar/TopSection/Header.vue
  93. 10
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  94. 35
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  95. 4
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  96. 4
      packages/nc-gui/components/dashboard/TreeView/TableList.vue
  97. 2
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  98. 57
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  99. 6
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  100. 72
      packages/nc-gui/components/dashboard/TreeView/index.vue
  101. Some files were not shown because too many files have changed in this diff Show More

2
.github/ISSUE_TEMPLATE/--bug-report.yaml

@ -10,7 +10,7 @@ body:
- type: checkboxes
attributes:
label: Please confirm if bug report does NOT exists already ?
label: Please confirm if bug report does NOT exist already ?
description: We kindly ask that you [search](https://github.com/nocodb/nocodb/issues?q=is%3Aissue+sort%3Acreated-desc+) to see if an issue already exists for your bug
options:
- label: I confirm there is no existing issue for this

2
.github/ISSUE_TEMPLATE/--feature-request.yaml

@ -10,7 +10,7 @@ body:
- type: checkboxes
attributes:
label: Please confirm if feature request does NOT exists already ?
label: Please confirm if feature request does NOT exist already ?
description: We kindly ask that you [search](https://github.com/nocodb/nocodb/issues?q=is%3Aissue+sort%3Acreated-desc+) to see if an issue already exists for your feature
options:
- label: I confirm there is no existing issue for this

2
.github/uffizzi/docker-compose.uffizzi.yml

@ -31,7 +31,7 @@ services:
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
MYSQL_USER: noco
image: "mysql:8.0.35"
image: "mysql:8.3.0"
deploy:
resources:
limits:

4
.github/workflows/ci-cd.yml

@ -40,7 +40,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: 18.19.0
- name: Checkout
uses: actions/checkout@v3
with:
@ -75,7 +75,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: 18.19.0
- name: Checkout
uses: actions/checkout@v3
with:

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

@ -23,7 +23,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: 18.19.0
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:

2
.github/workflows/pre-build-for-playwright.yml

@ -13,7 +13,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: 18.19.0
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:

2
.github/workflows/publish-blog.yml

@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- name: Build blogs
run: |
cd packages/noco-blog

2
.github/workflows/publish-dev-docs.yml

@ -20,7 +20,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- name: Build docs
run: |
cd packages/noco-docs

2
.github/workflows/publish-docs.yml

@ -19,7 +19,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- name: Build docs
run: |
cd packages/noco-docs

2
.github/workflows/publish-noco-i18n.yml

@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- name: Build noco-i18n
run: |
cd packages/noco-i18n

2
.github/workflows/publish-prev-docs.yml

@ -19,7 +19,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- name: Build prev docs
run: |
cd packages/noco-docs-prev

2
.github/workflows/release-docker.yml

@ -84,7 +84,7 @@ jobs:
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: 18.19.0
- name: upgrade packages for nightly build or pr build
if: ${{ github.event.inputs.targetEnv == 'DEV' || inputs.targetEnv == 'DEV' }}

2
.github/workflows/release-draft.yml

@ -70,5 +70,5 @@ jobs:
})
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- run: "npx github-release-notes@0.17.2 release --token ${{ secrets.GITHUB_TOKEN }} --draft --tags ${{ github.event.inputs.tag || inputs.tag }}..${{ github.event.inputs.prev_tag || inputs.prev_tag }}"

2
.github/workflows/release-executables.yml

@ -63,7 +63,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- name : Install nocodb, other dependencies and build executables
run: |

4
.github/workflows/release-npm.yml

@ -46,11 +46,11 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.ref }}
- name: pnpm Setup and Publish with 18.14.0
- name: pnpm Setup and Publish with 18.19.0
# Setup .npmrc file to publish to npm
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: 18.19.0
registry-url: 'https://registry.npmjs.org'
- run: |
export NODE_OPTIONS="--max_old_space_size=16384"

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

@ -64,7 +64,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 18.19.0
- name: Update nocodb-timely
env:

2
.github/workflows/update-sdk-path.yml

@ -16,7 +16,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.14.0
node-version: 18.19.0
- name: Checkout
uses: actions/checkout@v3
with:

2
.npmrc

@ -1,3 +1,3 @@
engine-strict=true
shamefully-hoist=true
use-node-version=18.14.0
use-node-version=18.19.0

4
README.md

@ -15,7 +15,7 @@ Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart spreadshe
<div align="center">
[![Node version](https://img.shields.io/badge/node-%3E%3D%2018.14.0-brightgreen)](http://nodejs.org/download/)
[![Node version](https://img.shields.io/badge/node-%3E%3D%2018.19.0-brightgreen)](http://nodejs.org/download/)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-green.svg)](https://conventionalcommits.org)
</div>
@ -250,7 +250,7 @@ Access Dashboard using: [http://localhost:8080/dashboard](http://localhost:8080/
### App Store for Workflow Automations
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.
We provide different integrations in three main categories. See <a href="https://docs.nocodb.com/account-settings/oss-specific-details/#app-store" target="_blank">App Store</a> for details.
- ⚡ &nbsp;Chat: Slack, Discord, Mattermost, and etc
- ⚡ &nbsp;Email: AWS SES, SMTP, MailerSend, and etc

2
docker-compose/mysql/docker-compose.yml

@ -27,7 +27,7 @@ services:
- "-h"
- localhost
timeout: 20s
image: "mysql:8.0.35"
image: "mysql:8.3.0"
restart: always
volumes:
- "db_data:/var/lib/mysql"

2
docker-compose/nginx-proxy-manager/docker-compose.yml

@ -46,7 +46,7 @@ services:
- "-h"
- localhost
timeout: 20s
image: "mysql:8.0.35"
image: "mysql:8.3.0"
networks:
- default
restart: always

551
docker-compose/setup-script/noco.sh

@ -0,0 +1,551 @@
#!/bin/bash
# set -x
# ******************************************************************************
# ***************** HELPER FUNCTIONS START *********************************
# Function to URL encode special characters in a string
urlencode() {
local string="$1"
local strlen=${#string}
local encoded=""
local pos c o
for (( pos=0 ; pos<strlen ; pos++ )); do
c=${string:$pos:1}
case "$c" in
[-_.~a-zA-Z0-9] ) o="$c" ;;
* ) printf -v o '%%%02X' "'$c"
esac
encoded+="$o"
done
echo "$encoded"
}
# function to print a message in a box
print_box_message() {
message=("$@") # Store all arguments in the array "message"
edge="======================================"
padding=" "
echo "$edge"
for element in "${message[@]}"; do
echo "${padding}${element}"
done
echo "$edge"
}
# check command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# install package based on platform
install_package() {
if command_exists yum; then
sudo yum install -y "$1"
elif command_exists apt; then
sudo apt install -y "$1"
elif command_exists brew; then
brew install "$1"
else
echo "Package manager not found. Please install $1 manually."
fi
}
# Function to check if sudo is required for Docker Compose command
check_for_docker_compose_sudo() {
if docker-compose ps >/dev/null 2>&1; then
echo "n"
else
echo "y"
fi
}
# ***************** HELPER FUNCTIONS END ***********************************
# ******************************************************************************
# ******************************************************************************
# ******************** SYSTEM REQUIREMENTS CHECK START *************************
# Check if the following requirements are met:
# a. docker, docker-compose, jq installed
# b. port mapping check : 80,443 are free or being used by nginx container
REQUIRED_PORTS=(80 443)
echo "** Performing nocodb system check and setup. This step may require sudo permissions"
# pre install wget if not found
if ! command_exists wget; then
echo "wget is not installed. Setting up for installation..."
install_package wget
fi
# d. Check if required tools are installed
echo " | Checking if required tools (docker, docker-compose, lsof) are installed..."
for tool in docker docker-compose lsof openssl; do
if ! command_exists "$tool"; then
echo "$tool is not installed. Setting up for installation..."
if [ "$tool" = "docker-compose" ]; then
sudo -E curl -L https://github.com/docker/compose/releases/download/1.29.0/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
elif [ "$tool" = "docker" ]; then
wget -qO- https://get.docker.com/ | sh
elif [ "$tool" = "lsof" ]; then
install_package lsof
fi
fi
done
# e. Check if NocoDB is already installed and its expected version
# echo "Checking if NocoDB is already installed and its expected version..."
# Replace the following command with the actual command to check NocoDB installation and version
# Example: nocodb_version=$(command_to_get_nocodb_version)
# echo "NocoDB version: $nocodb_install_version"
# f. Port mapping check
echo " | Checking port accessibility..."
for port in "${REQUIRED_PORTS[@]}"; do
if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null; then
echo " | WARNING: Port $port is in use. Please make sure it is free." >&2
else
echo " | Port $port is free."
fi
done
echo "** System check completed successfully. **"
# Define an array to store the messages to be printed at the end
message_arr=()
# extract public ip address
PUBLIC_IP=$(dig +short myip.opendns.com @resolver1.opendns.com)
# Check if the public IP address is not empty, if empty then use the localhost
if [ -z "$PUBLIC_IP" ]; then
PUBLIC_IP="localhost"
fi
# generate a folder for the docker-compose file which is not existing and do the setup within the folder
# Define the folder name
FOLDER_NAME="nocodb_$(date +"%Y%m%d_%H%M%S")"
# prompt for custom folder name and if left empty skip
#echo "Enter a custom folder name or press Enter to use the default folder name ($FOLDER_NAME): "
#read CUSTOM_FOLDER_NAME
message_arr+=("Setup folder: $FOLDER_NAME")
if [ -n "$CUSTOM_FOLDER_NAME" ]; then
FOLDER_NAME="$CUSTOM_FOLDER_NAME"
fi
# Create the folder
mkdir -p "$FOLDER_NAME"
# Navigate into the folder
cd "$FOLDER_NAME" || exit
# ******************** SYSTEM REQUIREMENTS CHECK END **************************
# ******************************************************************************
# ******************** INPUTS FROM USER START ********************************
# ******************************************************************************
echo "Choose Community or Enterprise Edition [CE/EE] (default: CE): "
read EDITION
echo "Do you want to configure SSL [Y/N] (default: N): "
read SSL_ENABLED
if [ -n "$SSL_ENABLED" ] && { [ "$SSL_ENABLED" = "Y" ] || [ "$SSL_ENABLED" = "y" ]; }; then
SSL_ENABLED='y'
echo "Enter the domain name for the SSL certificate: "
read DOMAIN_NAME
if [ -z "$DOMAIN_NAME" ]; then
echo "Domain name is required for SSL configuration"
exit 1
fi
message_arr+=("Domain: $DOMAIN_NAME")
else
# prompt for ip address and if left empty use extracted public ip
echo "Enter the IP address or domain name for the NocoDB instance (default: $PUBLIC_IP): "
read DOMAIN_NAME
if [ -z "$DOMAIN_NAME" ]; then
DOMAIN_NAME="$PUBLIC_IP"
fi
fi
if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; then
echo "Enter the NocoDB license key: "
read LICENSE_KEY
if [ -z "$LICENSE_KEY" ]; then
echo "License key is required for Enterprise Edition installation"
exit 1
fi
fi
echo "Do you want to enabled Redis for caching [Y/N] (default: Y): "
read REDIS_ENABLED
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then
message_arr+=("Redis: Enabled")
else
message_arr+=("Redis: Disabled")
fi
echo "Do you want to enabled Watchtower for automatic updates [Y/N] (default: Y): "
read WATCHTOWER_ENABLED
if [ -z "$WATCHTOWER_ENABLED" ] || { [ "$WATCHTOWER_ENABLED" != "N" ] && [ "$WATCHTOWER_ENABLED" != "n" ]; }; then
message_arr+=("Watchtower: Enabled")
else
message_arr+=("Watchtower: Disabled")
fi
# ******************************************************************************
# *********************** INPUTS FROM USER END ********************************
# ******************************************************************************
# *************************** SETUP START *************************************
# Generate a strong random password for PostgreSQL
STRONG_PASSWORD=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9!@#$%^&*()-_+=' | head -c 32)
REDIS_PASSWORD=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 24)
# Encode special characters in the password for JDBC URL usage
ENCODED_PASSWORD=$(urlencode "$STRONG_PASSWORD")
IMAGE="nocodb/nocodb:latest";
# Determine the Docker image to use based on the edition
if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; then
IMAGE="nocodb/nocodb-ee:latest"
DATABASE_URL="DATABASE_URL=postgres://postgres:${ENCODED_PASSWORD}@db:5432/nocodb"
else
# use NC_DB url until the issue with DATABASE_URL is resolved(encoding)
DATABASE_URL="NC_DB=pg://db:5432?d=nocodb&user=postgres&password=${ENCODED_PASSWORD}"
fi
message_arr+=("Docker image: $IMAGE")
DEPENDS_ON=""
# Add Redis service if enabled
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then
DEPENDS_ON="- redis"
fi
# Write the Docker Compose file with the updated password
cat <<EOF > docker-compose.yml
version: '3'
services:
nocodb:
image: ${IMAGE}
env_file: docker.env
depends_on:
- db
${DEPENDS_ON}
restart: unless-stopped
volumes:
- ./nocodb:/usr/app/data
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
- nocodb-network
db:
image: postgres:16.1
env_file: docker.env
volumes:
- ./postgres:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
interval: 10s
retries: 10
test: "pg_isready -U \"\$\$POSTGRES_USER\" -d \"\$\$POSTGRES_DB\""
timeout: 2s
networks:
- nocodb-network
nginx:
image: nginx:latest
volumes:
- ./nginx:/etc/nginx/conf.d
EOF
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
cat <<EOF >> docker-compose.yml
- webroot:/var/www/certbot
- ./letsencrypt:/etc/letsencrypt
- letsencrypt-lib:/var/lib/letsencrypt
EOF
fi
cat <<EOF >> docker-compose.yml
ports:
- "80:80"
- "443:443"
depends_on:
- nocodb
restart: unless-stopped
networks:
- nocodb-network
EOF
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
cat <<EOF >> docker-compose.yml
certbot:
image: certbot/certbot
volumes:
- ./letsencrypt:/etc/letsencrypt
- letsencrypt-lib:/var/lib/letsencrypt
- webroot:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait \$\${!}; done;'"
depends_on:
- nginx
restart: unless-stopped
networks:
- nocodb-network
EOF
fi
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then
cat <<EOF >> docker-compose.yml
redis:
image: redis:latest
restart: unless-stopped
env_file: docker.env
command:
- /bin/sh
- -c
- redis-server --requirepass "\$\${REDIS_PASSWORD}"
volumes:
- redis:/data
healthcheck:
test: [ "CMD", "redis-cli", "-a", "\$\${REDIS_PASSWORD}", "--raw", "incr", "ping" ]
networks:
- nocodb-network
EOF
fi
if [ -z "$WATCHTOWER_ENABLED" ] || { [ "$WATCHTOWER_ENABLED" != "N" ] && [ "$WATCHTOWER_ENABLED" != "n" ]; }; then
cat <<EOF >> docker-compose.yml
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --schedule "0 2 * * 6" --cleanup
restart: unless-stopped
networks:
- nocodb-network
EOF
fi
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
cat <<EOF >> docker-compose.yml
volumes:
letsencrypt-lib:
webroot:
EOF
fi
# add the cache volume
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then
# check ssl enabled
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
cat <<EOF >> docker-compose.yml
redis:
EOF
else
cat <<EOF >> docker-compose.yml
volumes:
redis:
EOF
fi
fi
# Create the network
cat <<EOF >> docker-compose.yml
networks:
nocodb-network:
driver: bridge
EOF
# Write the docker.env file
cat <<EOF > docker.env
POSTGRES_DB=nocodb
POSTGRES_USER=postgres
POSTGRES_PASSWORD=${STRONG_PASSWORD}
$DATABASE_URL
NC_LICENSE_KEY=${LICENSE_KEY}
EOF
# add redis env if enabled
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then
cat <<EOF >> docker.env
REDIS_PASSWORD=${REDIS_PASSWORD}
NC_REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0
EOF
fi
mkdir -p ./nginx
# Create nginx config with the provided domain name
cat > ./nginx/default.conf <<EOF
server {
listen 80;
EOF
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
cat >> ./nginx/default.conf <<EOF
server_name $DOMAIN_NAME;
EOF
fi
cat >> ./nginx/default.conf <<EOF
location / {
proxy_pass http://nocodb:8080;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
EOF
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
cat >> ./nginx/default.conf <<EOF
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
EOF
fi
cat >> ./nginx/default.conf <<EOF
}
EOF
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
mkdir -p ./nginx-post-config
# Create nginx config with the provided domain name
cat > ./nginx-post-config/default.conf <<EOF
server {
listen 80;
server_name $DOMAIN_NAME;
location / {
return 301 https://\$host\$request_uri;
}
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
server {
listen 443 ssl;
server_name $DOMAIN_NAME;
ssl_certificate /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem;
location / {
proxy_pass http://nocodb:8080;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
}
EOF
fi
IS_DOCKER_COMPOSE_REQUIRE_SUDO=$(check_for_docker_compose_sudo)
# Generate the update.sh file for upgrading images
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then
cat > ./update.sh <<EOF
sudo docker-compose pull
sudo docker-compose up -d --force-recreate
sudo docker image prune -a -f
EOF
else
cat > ./update.sh <<EOF
docker-compose pull
docker-compose up -d --force-recreate
docker image prune -a -f
EOF
fi
message_arr+=("Update script: update.sh")
# Pull latest images and start the docker-compose setup
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then
echo "Docker compose requires sudo. Running the docker-compose setup with sudo."
sudo docker-compose pull
sudo docker-compose up -d
else
docker-compose pull
docker-compose up -d
fi
echo 'Waiting for Nginx to start...';
sleep 5
if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
echo 'Starting Letsencrypt certificate request...';
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then
sudo docker-compose exec certbot certbot certonly --webroot --webroot-path=/var/www/certbot -d $DOMAIN_NAME --email contact@$DOMAIN_NAME --agree-tos --no-eff-email && echo "Certificate request successful" || echo "Certificate request failed"
else
docker-compose exec certbot certbot certonly --webroot --webroot-path=/var/www/certbot -d $DOMAIN_NAME --email contact@$DOMAIN_NAME --agree-tos --no-eff-email && echo "Certificate request successful" || echo "Certificate request failed"
fi
# Initial Let's Encrypt certificate request
# Update the nginx config to use the new certificates
rm -rf ./nginx/default.conf
mv ./nginx-post-config/default.conf ./nginx/
rm -r ./nginx-post-config
echo "Restarting nginx to apply the new certificates"
# Reload nginx to apply the new certificates
if [ "$IS_DOCKER_COMPOSE_REQUIRE_SUDO" = "y" ]; then
sudo docker-compose exec nginx nginx -s reload
else
docker-compose exec nginx nginx -s reload
fi
message_arr+=("NocoDB is now available at https://$DOMAIN_NAME")
elif [ -n "$DOMAIN_NAME" ]; then
message_arr+=("NocoDB is now available at http://$DOMAIN_NAME")
else
message_arr+=("NocoDB is now available at http://localhost")
fi
print_box_message "${mecdessage_arr[@]}"
# *************************** SETUP END *************************************
# ******************************************************************************

1
markdown/readme/languages/README.md

@ -16,4 +16,5 @@ Supported Translations:
<li><a href="russian.md">Russian</a></li>
<li><a href="dutch.md">Dutch</a></li>
<li><a href="indonesian.md">Indonesian</a></li>
<li><a href="ukrainian.md">Ukrainian</a></li>
</ul>

283
markdown/readme/languages/ukrainian.md

@ -0,0 +1,283 @@
<h1 align="center" style="border-bottom: none">
<div>
<a href="https://www.nocodb.com">
<img src="/packages/nc-gui/assets/img/icons/512x512.png" width="80" />
<br>
NocoDB
</a>
</div>
Опенсорс альтернатива Airtable <br>
</h1>
<p align="center">
Nocodb перетворює будь-яку базу даних MySQL, PostgreSQL, SQL Server, SQLite та MariaDB в розумну електронну таблицю.
</p>
<div align="center">
[![Node version](https://img.shields.io/badge/node-%3E%3D%2018.19.0-brightgreen)](http://nodejs.org/download/)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-green.svg)](https://conventionalcommits.org)
</div>
<p align="center">
<a href="http://www.nocodb.com"><b>Сайт</b></a>
<a href="https://discord.gg/5RgZmkW"><b>Discord</b></a>
<a href="https://community.nocodb.com/"><b>Спільнота</b></a>
<a href="https://twitter.com/nocodb"><b>Twitter</b></a>
<a href="https://www.reddit.com/r/NocoDB/"><b>Reddit</b></a>
<a href="https://docs.nocodb.com/"><b>Документація</b></a>
</p>
![video avi](https://github.com/nocodb/nocodb/assets/86527202/e2fad786-f211-4dcb-9bd3-aaece83a6783)
<div align="center">
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263434-75fe793d-42af-49e4-b964-d70920e41655.png">](markdown/readme/languages/chinese.md)
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263474-787d71e7-3a87-42a8-92a8-be1d1f55413d.png">](markdown/readme/languages/french.md)
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263531-fae58600-6616-4b43-95a0-5891019dd35d.png">](markdown/readme/languages/german.md)
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263669-f567196a-d4e8-4143-a80a-93d3be32ba90.png">](markdown/readme/languages/portuguese.md)
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263707-ba4e04a4-268a-4626-91b8-048e572fd9f6.png">](markdown/readme/languages/italian.md)
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263770-38e3e79d-11d4-472e-ac27-ae0f17cf65c4.png">](markdown/readme/languages/japanese.md)
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263822-28fce9de-915a-44dc-962d-7a61d340e91d.png">](markdown/readme/languages/korean.md)
[<img height="38" src="https://user-images.githubusercontent.com/61551451/135263589-3dbeda9a-0d2e-4bbd-b1fc-691404bb74fb.png">](markdown/readme/languages/spanish.md)
</div>
<p align="center"><a href="markdown/readme/languages/README.md"><b>Дивіться інші мови »</b></a></p>
<img src="https://static.scarf.sh/a.png?x-pxid=c12a77cc-855e-4602-8a0f-614b2d0da56a" />
# Приєднуйтеся до нашої команди
<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>
# Приєднуйтеся до нашої спільноти
<a href="https://discord.gg/5RgZmkW" target="_blank">
<img src="https://discordapp.com/api/guilds/661905455894888490/widget.png?style=banner3" alt="">
</a>
<!-- <a href="https://community.nocodb.com/" target="_blank">
<img src="https://i2.wp.com/www.feverbee.com/wp-content/uploads/2018/07/logo-discourse.png" alt="">
</a>
-->
[![Stargazers repo roster for @nocodb/nocodb](http://reporoster.com/stars/nocodb/nocodb)](https://github.com/nocodb/nocodb/stargazers)
# Швидка спроба проекту
## Docker
```bash
# для SQLite
docker run -d --name nocodb \
-v "$(pwd)"/nocodb:/usr/app/data/ \
-p 8080:8080 \
nocodb/nocodb:latest
# для MySQL
docker run -d --name nocodb-mysql \
-v "$(pwd)"/nocodb:/usr/app/data/ \
-p 8080:8080 \
-e NC_DB="mysql2://host.docker.internal:3306?u=root&p=password&d=d1" \
-e NC_AUTH_JWT_SECRET="569a1821-0a93-45e8-87ab-eb857f20a010" \
nocodb/nocodb:latest
# для PostgreSQL
docker run -d --name nocodb-postgres \
-v "$(pwd)"/nocodb:/usr/app/data/ \
-p 8080:8080 \
-e NC_DB="pg://host.docker.internal:5432?u=root&p=password&d=d1" \
-e NC_AUTH_JWT_SECRET="569a1821-0a93-45e8-87ab-eb857f20a010" \
nocodb/nocodb:latest
# для MSSQL
docker run -d --name nocodb-mssql \
-v "$(pwd)"/nocodb:/usr/app/data/ \
-p 8080:8080 \
-e NC_DB="mssql://host.docker.internal:1433?u=root&p=password&d=d1" \
-e NC_AUTH_JWT_SECRET="569a1821-0a93-45e8-87ab-eb857f20a010" \
nocodb/nocodb:latest
```
> Щоб зберегти дані в Docker, ви можете змонтувати том в /usr/app/data/ з версії 0.10.6. В іншому випадку ваші дані будуть втрачені після перестворення контейнера.
> Якщо ви плануєте вводити будь-які спеціальні символи, вам може знадобитися змінити набір символів та порівняння при створенні бази даних. Будь ласка, перегляньте приклади для [MySQL Docker](https://github.com/nocodb/nocodb/issues/1340#issuecomment-1049481043).
> Різні команди лише вказують базу даних, яку NocoDB буде використовувати для зберігання метаданих, але це не впливає на можливість підключення до іншого типу бази даних.
## Binaries
##### MacOS (x64)
```bash
curl http://get.nocodb.com/macos-x64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### MacOS (arm64)
```bash
curl http://get.nocodb.com/macos-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### Linux (x64)
```bash
curl http://get.nocodb.com/linux-x64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### Linux (arm64)
```bash
curl http://get.nocodb.com/linux-arm64 -o nocodb -L && chmod +x nocodb && ./nocodb
```
##### Windows (x64)
```bash
iwr http://get.nocodb.com/win-x64.exe -o Noco-win-x64.exe
.\Noco-win-x64.exe
```
##### Windows (arm64)
```bash
iwr http://get.nocodb.com/win-arm64.exe -o Noco-win-arm64.exe
.\Noco-win-arm64.exe
```
## Docker Compose
Ми надаємо різні приклад конфігурацій docker-compose.yml у [цьому каталозі](https://github.com/nocodb/nocodb/tree/master/docker-compose). Ось деякі приклади.
```bash
git clone https://github.com/nocodb/nocodb
# для MySQL
cd nocodb/docker-compose/mysql
# для PostgreSQL
cd nocodb/docker-compose/pg
# для MSSQL
cd nocodb/docker-compose/mssql
docker-compose up -d
```
> Щоб зберегти дані в Docker, ви можете змонтувати том в /usr/app/data/ з версії 0.10.6. В іншому випадку ваші дані будуть втрачені після перестворення контейнера.
> Якщо ви плануєте вводити будь-які спеціальні символи, вам може знадобитися змінити набір символів та порівняння при створенні бази даних. Будь ласка, перегляньте приклади для [MySQL Docker](https://github.com/nocodb/nocodb/issues/1340#issuecomment-1049481043).
## NPX
Ви можете запустити нижченаведену команду, якщо вам потрібна інтерактивна конфігурація.
```
npx create-nocodb-app
```
<img src="https://user-images.githubusercontent.com/35857179/163672964-00ef5d62-0434-447d-ac01-3ebb780099b9.png" width="520px"/>
## Node Application
Для початку ви можете використати простий Node.js застосунок.
```bash
git clone https://github.com/nocodb/nocodb-seed
cd nocodb-seed
npm install
npm start
```
# GUI
Доступ до панелі інструментів за адресою: [http://localhost:8080/dashboard](http://localhost:8080/dashboard)
# Screenshots
![2](https://github.com/nocodb/nocodb/assets/86527202/a127c05e-2121-4af2-a342-128e0e2d0291)
![3](https://github.com/nocodb/nocodb/assets/86527202/674da952-8a06-4848-a0e8-a7b02d5f5c88)
![4](https://github.com/nocodb/nocodb/assets/86527202/cbc5152a-9caf-4f77-a8f7-92a9d06d025b)
![5](https://github.com/nocodb/nocodb/assets/86527202/dc75dfdc-c486-4f5a-a853-2a8f9e6b569a)
![5](https://user-images.githubusercontent.com/35857179/194844886-a17006e0-979d-493f-83c4-0e72f5a9b716.png)
![7](https://github.com/nocodb/nocodb/assets/86527202/be64e619-7295-43e2-aa95-cace4462b17f)
![8](https://github.com/nocodb/nocodb/assets/86527202/4538bf5a-371f-4ec1-a867-8197e5824286)
![8](https://user-images.githubusercontent.com/35857179/194844893-82d5e21b-ae61-41bd-9990-31ad659bf490.png)
![9](https://user-images.githubusercontent.com/35857179/194844897-cfd79946-e413-4c97-b16d-eb4d7678bb79.png)
![10](https://user-images.githubusercontent.com/35857179/194844902-c0122570-0dd5-41cf-a26f-6f8d71fefc99.png)
![11](https://user-images.githubusercontent.com/35857179/194844903-c1e47f40-e782-4f5d-8dce-6449cc70b181.png)
![12](https://user-images.githubusercontent.com/35857179/194844907-09277d3e-cbbf-465c-9165-6afc4161e279.png)
# Функції
### Багатий інтерфейс таблиць
- ⚡ &nbsp;Основні операції: Створення, Читання, Оновлення та Видалення Таблиць, Стовпців та Рядків
- ⚡ &nbsp;Операції з полями: Сортування, Фільтрація, Приховування / Розкриття Стовпців
- ⚡ &nbsp;Типи переглядів: Сітка (за замовчуванням), Галерея, Форма та Канбан
- ⚡ &nbsp;Типи дозволів для переглядів: Спільний доступ для переглядів та Заблоковані перегляди
- ⚡ &nbsp;Поділ баз / переглядів: Публічно або Приватно (з паролем)
- ⚡ &nbsp;Варіанти типів клітинок: ID, ПосиланняНаІншийЗапис, Пошук, Сума, ОдноРядковийТекст, Вкладення, Валюта, Формула, тощо
- ⚡ &nbsp;Контроль доступу за ролями: Деталізований контроль доступу на різних рівнях
- ⚡ &nbsp;і більше ...
### Широкий вибір застосунків для автоматизації робочих процесів
Ми надаємо різні інтеграції у трьох основних категоріях. Деталі дивіться у <a href="https://docs.nocodb.com/setup-and-usages/account-settings#app-store" target="_blank">App Store</a>.
- ⚡ &nbsp;Чат: Slack, Discord, Mattermost, тощо
- ⚡ &nbsp;Електронна пошта: AWS SES, SMTP, MailerSend, тощо
- ⚡ &nbsp;Сховище: AWS S3, Google Cloud Storage, Minio, тощо
### API доступ
Ми надаємо різні способи, якими користувачі можуть програмно викликати дії. Ви можете використовувати токен (або JWT, або соціальний авторизаційний токен) для підписання ваших запитань для авторизації в NocoDB.
- ⚡ &nbsp;REST API
- ⚡ &nbsp;NocoDB SDK
### Синхронізація схеми
Ми дозволяємо вам синхронізувати зміни схеми, якщо ви внесли зміни поза NocoDB GUI. Проте слід зауважити, що вам доведеться мати власні міграції схеми для переміщення з одного середовища в інше. Деталі дивіться у <a href="https://docs.nocodb.com/setup-and-usages/sync-schema/" target="_blank">Sync Schema</a>.
### Аудит
Ми зберігаємо всі журнали операцій користувача в одному місці. Деталі дивіться у <a href="https://docs.nocodb.com/setup-and-usages/audit" target="_blank">Audit</a>.
# Налаштування продукції
За замовчуванням використовується SQLite для зберігання метаданих. Однак ви можете вказати свою базу даних. Параметри підключення до цієї бази даних можна вказати в змінній середовища `NC_DB`. Крім того, ми також надаємо наступні змінні середовища для налаштувань.
## Змінні середовища
Будь ласка, звертайтеся до [Змінні середовища](https://docs.nocodb.com/getting-started/environment-variables)
# Налаштування розробки
Будь ласка, перегляньте всю необхідну інформацію тут [Development Setup](https://docs.nocodb.com/engineering/development-setup)
# Вклад у проект
Будь ласка, перегляньте всю необхідну інформацію тут [Contribution Guide](https://github.com/nocodb/nocodb/blob/master/.github/CONTRIBUTING.md).
# Чому ми створюємо цей проект?
Більшість інтернет-бізнесів використовують електронні таблиці, або бази даних для вирішення своїх бізнес-потреб. Електронні таблиці використовуються мільярдами людей кожен день. Однак ми далекі від роботи на подібних швидкостях у роботі з базами даних, які є набагато потужнішими інструментами, коли мова йде про обчислення. Спроби вирішити це за допомогою пропозицій SaaS приводить до жахливого контролю доступу, в'язницю від постачальників хмарних сервісів, в'язницю даних, раптові зміни цін та, що найважливіше, скляну стелю щодо того, що можливо у майбутньому.
# Наша місія
Наша місія - надати найпотужніший no-code інтерфейс для баз даних, код яких є відкритим для кожного інтернет-бізнесу в світі. Це не лише демократизує доступ до потужного інструмента для обчислень, але також приведе до того, що мільярди людей матимуть неймовірні можливості до експериментів та створення проектів в інтернеті.
# Ліцензія
<p>
Цей проект ліцензується за <a href="./LICENSE">AGPLv3</a>.
</p>
# Співтовариші
Дякуємо за ваші внески! Ми вдячні за всі внески від спільноти.
<a href="https://github.com/nocodb/nocodb/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nocodb/nocodb" />
</a>

2
packages/nc-gui/.eslintrc.js

@ -15,5 +15,5 @@ module.exports = {
extends: ['@antfu', 'plugin:prettier/recommended'],
plugins: ['prettier'],
rules: baseRules,
ignorePatterns: ['!*.d.ts', 'components.d.ts'],
ignorePatterns: ['!*.d.ts', 'components.d.ts', '**/typesense-docsearch.js'],
}

66
packages/nc-gui/app.vue

@ -1,14 +1,21 @@
<script setup lang="ts">
import { applyNonSelectable, computed, useRouter, useTheme } from '#imports'
import { applyNonSelectable, computed, isEeUI, isMac, useCommandPalette, useRouter, useTheme } from '#imports'
import type { CommandPaletteType } from '~/lib'
const router = useRouter()
const route = router.currentRoute
const cmdK = ref(false)
const cmdL = ref(false)
const disableBaseLayout = computed(() => route.value.path.startsWith('/nc/view') || route.value.path.startsWith('/nc/form'))
useTheme()
const { commandPalette, cmdData, cmdPlaceholder, activeScope, loadTemporaryScope, refreshCommandPalette } = useCommandPalette()
applyNonSelectable()
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
@ -19,6 +26,16 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
if (!['input', 'textarea'].includes((e.target as any).nodeName.toLowerCase())) {
e.preventDefault()
}
break
case 'k':
e.preventDefault()
break
case 'l':
e.preventDefault()
break
case 'j':
e.preventDefault()
break
}
}
})
@ -47,6 +64,38 @@ if (typeof window !== 'undefined') {
// @ts-expect-error using arbitrary window key
window.__ncvue = true
}
function onScope(scope: string) {
if (scope === 'root' && isEeUI) {
loadTemporaryScope({ scope: 'root', data: {} })
}
}
function setActiveCmdView(cmd: CommandPaletteType) {
if (cmd === 'cmd-k') {
cmdK.value = true
cmdL.value = false
} else if (cmd === 'cmd-l') {
cmdL.value = true
cmdK.value = false
} else {
cmdL.value = false
cmdK.value = false
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'J',
ctrlKey: !isMac() || undefined,
metaKey: isMac() || undefined,
}),
)
}
}
onMounted(() => {
nextTick(() => {
refreshCommandPalette()
})
})
</script>
<template>
@ -55,4 +104,19 @@ if (typeof window !== 'undefined') {
<NuxtPage :key="key" :transition="false" />
</NuxtLayout>
</a-config-provider>
<!-- Command Menu -->
<CmdK
ref="commandPalette"
v-model:open="cmdK"
:scope="activeScope.scope"
:data="cmdData"
:placeholder="cmdPlaceholder"
:load-temporary-scope="loadTemporaryScope"
:set-active-cmd-view="setActiveCmdView"
@scope="onScope"
/>
<!-- Recent Views. Cycles through recently visited Views -->
<CmdL v-model:open="cmdL" :set-active-cmd-view="setActiveCmdView" />
<!-- Documentation. Integrated NocoDB Docs directlt inside the Product -->
<CmdJ />
</template>

2
packages/nc-gui/assets/css/typesense-docsearch.css

File diff suppressed because one or more lines are too long

20
packages/nc-gui/assets/img/brand/nocodb-logo.svg

@ -0,0 +1,20 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6 10.8676L8.75329 13.6226V17.9842H6V10.8676ZM17.5645 5.01046V17.535C17.5645 17.7921 17.3548 18 17.0977 18C16.9744 18 16.8563 17.9525 16.7683 17.8644L6 8.15303V5.40504C6 5.14785 6.20787 4.94 6.46505 4.94H6.48972C6.61303 4.94 6.7328 4.98933 6.81911 5.07564L14.8094 12.009V5.01046H17.5645Z"
fill="url(#paint0_linear_231_10218)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M3 0C1.34315 0 0 1.34315 0 3V21C0 22.6569 1.34315 24 3 24H21C22.6569 24 24 22.6569 24 21V3C24 1.34315 22.6569 0 21 0H3ZM3.63333 2.13333C2.8049 2.13333 2.13333 2.8049 2.13333 3.63333V20.3667C2.13333 21.1951 2.8049 21.8667 3.63333 21.8667H20.3667C21.1951 21.8667 21.8667 21.1951 21.8667 20.3667V3.63333C21.8667 2.8049 21.1951 2.13333 20.3667 2.13333H3.63333Z"
fill="url(#paint1_linear_231_10218)" />
<defs>
<linearGradient id="paint0_linear_231_10218" x1="11.7814" y1="0.543214" x2="11.7814" y2="21.6612"
gradientUnits="userSpaceOnUse">
<stop stop-color="#4351E8" />
<stop offset="1" stop-color="#2A1EA5" />
</linearGradient>
<linearGradient id="paint1_linear_231_10218" x1="4.82267" y1="19.1431" x2="26.7035" y2="-2.6331"
gradientUnits="userSpaceOnUse">
<stop stop-color="#4351E8" />
<stop offset="1" stop-color="#2A1EA5" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
packages/nc-gui/assets/img/form-banner-left.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
packages/nc-gui/assets/img/form-banner-right.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
packages/nc-gui/assets/img/placeholder/api-tokens.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
packages/nc-gui/assets/img/placeholder/invite-team.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

BIN
packages/nc-gui/assets/img/placeholder/link-records.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
packages/nc-gui/assets/img/placeholder/multi-field-editor.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
packages/nc-gui/assets/img/placeholder/table.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
packages/nc-gui/assets/img/placeholder/webhooks.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

6
packages/nc-gui/assets/js/typesense-docsearch.js

File diff suppressed because one or more lines are too long

4
packages/nc-gui/assets/nc-icons/arrow-down.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 3.33337V12.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.6666 8L7.99998 12.6667L3.33331 8" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

4
packages/nc-gui/assets/nc-icons/arrow-up.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12.6667V3.33337" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.33331 8.00004L7.99998 3.33337L12.6666 8.00004" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

3
packages/nc-gui/assets/nc-icons/chevron-down.svg

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 222 B

11
packages/nc-gui/assets/nc-icons/copy.svg

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_184_4881)">
<path d="M13.3333 6H7.33333C6.59695 6 6 6.59695 6 7.33333V13.3333C6 14.0697 6.59695 14.6667 7.33333 14.6667H13.3333C14.0697 14.6667 14.6667 14.0697 14.6667 13.3333V7.33333C14.6667 6.59695 14.0697 6 13.3333 6Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.33334 10H2.66667C2.31305 10 1.97391 9.85956 1.72386 9.60952C1.47381 9.35947 1.33334 9.02033 1.33334 8.66671V2.66671C1.33334 2.31309 1.47381 1.97395 1.72386 1.7239C1.97391 1.47385 2.31305 1.33337 2.66667 1.33337H8.66667C9.02029 1.33337 9.35943 1.47385 9.60948 1.7239C9.85953 1.97395 10 2.31309 10 2.66671V3.33337" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_184_4881">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 970 B

6
packages/nc-gui/assets/nc-icons/download.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66675 6.66663L8.00008 9.99996L11.3334 6.66663" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10V2" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66675 6.66663L8.00008 9.99996L11.3334 6.66663" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10V2" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 643 B

After

Width:  |  Height:  |  Size: 658 B

6
packages/nc-gui/assets/nc-icons/duplicate.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.0625 3.75H2.125C1.82663 3.75 1.54048 3.86853 1.3295 4.0795C1.11853 4.29048 1 4.57663 1 4.875V13.875C1 14.1734 1.11853 14.4595 1.3295 14.6705C1.54048 14.8815 1.82663 15 2.125 15H8.875C9.17337 15 9.45952 14.8815 9.67049 14.6705C9.88147 14.4595 10 14.1734 10 13.875V7.6875L6.0625 3.75Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.0625 3.75V7.6875H10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 3.5V1.875C5 1.57663 5.11853 1.29048 5.3295 1.0795C5.54048 0.868526 5.82663 0.75 6.125 0.75H10.0625L14 4.6875V10.875C14 11.1734 13.8815 11.4595 13.6705 11.6705C13.4595 11.8815 13.1734 12 12.875 12H10.0625" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0625 0.75V4.6875H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

11
packages/nc-gui/assets/nc-icons/edit.svg

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_184_5634)">
<path d="M7.33331 2.66663H2.66665C2.31302 2.66663 1.97389 2.8071 1.72384 3.05715C1.47379 3.3072 1.33331 3.64634 1.33331 3.99996V13.3333C1.33331 13.6869 1.47379 14.0261 1.72384 14.2761C1.97389 14.5262 2.31302 14.6666 2.66665 14.6666H12C12.3536 14.6666 12.6927 14.5262 12.9428 14.2761C13.1928 14.0261 13.3333 13.6869 13.3333 13.3333V8.66663" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.3333 1.66665C12.5985 1.40144 12.9582 1.25244 13.3333 1.25244C13.7084 1.25244 14.0681 1.40144 14.3333 1.66665C14.5985 1.93187 14.7475 2.29158 14.7475 2.66665C14.7475 3.04173 14.5985 3.40144 14.3333 3.66665L7.99998 9.99999L5.33331 10.6667L5.99998 7.99999L12.3333 1.66665Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_184_5634">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

2
packages/nc-gui/assets/nc-icons/eye.svg

@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.666504 8.00033C0.666504 8.00033 3.33317 2.66699 7.99984 2.66699C12.6665 2.66699 15.3332 8.00033 15.3332 8.00033C15.3332 8.00033 12.6665 13.3337 7.99984 13.3337C3.33317 13.3337 0.666504 8.00033 0.666504 8.00033Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.666656 7.99996C0.666656 7.99996 3.33332 2.66663 7.99999 2.66663C12.6667 2.66663 15.3333 7.99996 15.3333 7.99996C15.3333 7.99996 12.6667 13.3333 7.99999 13.3333C3.33332 13.3333 0.666656 7.99996 0.666656 7.99996Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 634 B

After

Width:  |  Height:  |  Size: 634 B

4
packages/nc-gui/assets/nc-icons/paste.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 2.66663H12C12.3536 2.66663 12.6928 2.8071 12.9428 3.05715C13.1928 3.3072 13.3333 3.64634 13.3333 3.99996V13.3333C13.3333 13.6869 13.1928 14.0261 12.9428 14.2761C12.6928 14.5262 12.3536 14.6666 12 14.6666H3.99999C3.64637 14.6666 3.30723 14.5262 3.05718 14.2761C2.80713 14.0261 2.66666 13.6869 2.66666 13.3333V3.99996C2.66666 3.64634 2.80713 3.3072 3.05718 3.05715C3.30723 2.8071 3.64637 2.66663 3.99999 2.66663H5.33332" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 1.33337H6.00001C5.63182 1.33337 5.33334 1.63185 5.33334 2.00004V3.33337C5.33334 3.70156 5.63182 4.00004 6.00001 4.00004H10C10.3682 4.00004 10.6667 3.70156 10.6667 3.33337V2.00004C10.6667 1.63185 10.3682 1.33337 10 1.33337Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 965 B

10
packages/nc-gui/assets/nc-icons/pencil.svg

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_184_4900)">
<path d="M11.3333 2.00004C11.5084 1.82494 11.7163 1.68605 11.9451 1.59129C12.1738 1.49653 12.419 1.44775 12.6667 1.44775C12.9143 1.44775 13.1595 1.49653 13.3883 1.59129C13.617 1.68605 13.8249 1.82494 14 2.00004C14.1751 2.17513 14.314 2.383 14.4088 2.61178C14.5035 2.84055 14.5523 3.08575 14.5523 3.33337C14.5523 3.58099 14.5035 3.82619 14.4088 4.05497C14.314 4.28374 14.1751 4.49161 14 4.66671L5 13.6667L1.33333 14.6667L2.33333 11L11.3333 2.00004Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_184_4900">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 790 B

3
packages/nc-gui/assets/nc-icons/phone-call.svg

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0334 3.83268C10.6845 3.95973 11.283 4.27819 11.7521 4.74731C12.2212 5.21642 12.5397 5.81486 12.6667 6.46602M10.0334 1.16602C11.3862 1.31631 12.6478 1.92213 13.6109 2.88402C14.574 3.84591 15.1814 5.10669 15.3334 6.45935M14.6667 11.7793V13.7793C14.6675 13.965 14.6294 14.1488 14.555 14.3189C14.4807 14.489 14.3716 14.6417 14.2348 14.7673C14.0979 14.8928 13.9364 14.9883 13.7605 15.0478C13.5847 15.1073 13.3983 15.1294 13.2134 15.1127C11.1619 14.8898 9.19137 14.1888 7.46004 13.066C5.84926 12.0425 4.48359 10.6768 3.46004 9.06602C2.33336 7.32682 1.6322 5.34668 1.41337 3.28602C1.39671 3.10166 1.41862 2.91586 1.4777 2.74043C1.53679 2.56501 1.63175 2.40381 1.75655 2.2671C1.88134 2.13038 2.03324 2.02116 2.20256 1.94636C2.37189 1.87157 2.55493 1.83286 2.74004 1.83268H4.74004C5.06357 1.8295 5.37723 1.94407 5.62254 2.15504C5.86786 2.36601 6.02809 2.65898 6.07337 2.97935C6.15779 3.61939 6.31434 4.24783 6.54004 4.85268C6.62973 5.0913 6.64915 5.35063 6.59597 5.59994C6.5428 5.84925 6.41928 6.07809 6.24004 6.25935L5.39337 7.10602C6.34241 8.77505 7.72434 10.157 9.39337 11.106L10.24 10.2593C10.4213 10.0801 10.6501 9.95658 10.8994 9.90341C11.1488 9.85024 11.4081 9.86965 11.6467 9.95935C12.2516 10.185 12.88 10.3416 13.52 10.426C13.8439 10.4717 14.1396 10.6348 14.3511 10.8843C14.5625 11.1339 14.6748 11.4524 14.6667 11.7793Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

9
packages/nc-gui/assets/nc-icons/project-gray.svg

@ -0,0 +1,9 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.99294 15.3098L14.548 12.7802C14.8177 12.6574 14.9569 12.4978 14.9659 12.3369H1C1.00896 12.4978 1.14826 12.6574 1.4179 12.7802L6.97294 15.3098C7.53075 15.5637 8.43514 15.5637 8.99294 15.3098Z" fill="#5F5F5F"/>
<path d="M14.9999 9.77881H1.00513V12.3366H14.9999V9.77881Z" fill="#5F5F5F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.99294 12.7517L14.548 10.2221C14.8177 10.0993 14.9569 9.93968 14.9659 9.77881H1C1.00896 9.93968 1.14826 10.0993 1.4179 10.2221L6.97294 12.7517C7.53075 13.0056 8.43514 13.0056 8.99294 12.7517Z" fill="#5F5F5F"/>
<path d="M14.9999 7.22119H1.00513V9.77897H14.9999V7.22119Z" fill="#5F5F5F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.99294 11.4729L14.548 8.94332C14.8177 8.82054 14.9569 8.66088 14.9659 8.5H1C1.00896 8.66088 1.14826 8.82054 1.4179 8.94332L6.97294 11.4729C7.53075 11.7269 8.43514 11.7269 8.99294 11.4729Z" fill="#C4C4C4"/>
<path d="M14.9997 4.66309H1.00488V8.50309H14.9997V4.66309Z" fill="#C4C4C4"/>
<path d="M14.5484 5.13991L8.99337 7.66947C8.43561 7.92348 7.53121 7.92348 6.9734 7.66947L1.41836 5.13991C0.860546 4.8859 0.860546 4.47408 1.41836 4.22007L6.9734 1.69051C7.53121 1.4365 8.43561 1.4365 8.99337 1.69051L14.5484 4.22007C15.1063 4.47408 15.1063 4.8859 14.5484 5.13991Z" fill="#C4C4C4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

4
packages/nc-gui/assets/nc-icons/rename.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13.3334H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 2.33328C11.2652 2.06806 11.6249 1.91907 12 1.91907C12.1857 1.91907 12.3696 1.95565 12.5412 2.02672C12.7128 2.09779 12.8687 2.20196 13 2.33328C13.1313 2.4646 13.2355 2.6205 13.3066 2.79208C13.3776 2.96367 13.4142 3.14756 13.4142 3.33328C13.4142 3.519 13.3776 3.7029 13.3066 3.87448C13.2355 4.04606 13.1313 4.20196 13 4.33328L4.66667 12.6666L2 13.3333L2.66667 10.6666L11 2.33328Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 707 B

6
packages/nc-gui/assets/nc-icons/trash.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4H3.33333H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.33333 4.00004V2.66671C5.33333 2.31309 5.4738 1.97395 5.72385 1.7239C5.9739 1.47385 6.31304 1.33337 6.66666 1.33337H9.33333C9.68695 1.33337 10.0261 1.47385 10.2761 1.7239C10.5262 1.97395 10.6667 2.31309 10.6667 2.66671V4.00004M12.6667 4.00004V13.3334C12.6667 13.687 12.5262 14.0261 12.2761 14.2762C12.0261 14.5262 11.6869 14.6667 11.3333 14.6667H4.66666C4.31304 14.6667 3.9739 14.5262 3.72385 14.2762C3.4738 14.0261 3.33333 13.687 3.33333 13.3334V4.00004H12.6667Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33333 7.33337V11.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66667 7.33337V11.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

6
packages/nc-gui/assets/nc-icons/upload.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3334 5.33333L8.00008 2L4.66675 5.33333" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 2V10" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 10V12.6667C14 13.0203 13.8595 13.3594 13.6095 13.6095C13.3594 13.8595 13.0203 14 12.6667 14H3.33333C2.97971 14 2.64057 13.8595 2.39052 13.6095C2.14048 13.3594 2 13.0203 2 12.6667V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3333 5.33333L8 2L4.66667 5.33333" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 2V10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 637 B

After

Width:  |  Height:  |  Size: 646 B

4
packages/nc-gui/assets/nc-icons/user.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3334 14V12.6667C13.3334 11.9594 13.0524 11.2811 12.5523 10.781C12.0522 10.281 11.3739 10 10.6667 10H5.33335C4.62611 10 3.94783 10.281 3.44774 10.781C2.94764 11.2811 2.66669 11.9594 2.66669 12.6667V14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.99998 7.33333C9.47274 7.33333 10.6666 6.13943 10.6666 4.66667C10.6666 3.19391 9.47274 2 7.99998 2C6.52722 2 5.33331 3.19391 5.33331 4.66667C5.33331 6.13943 6.52722 7.33333 7.99998 7.33333Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 707 B

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

@ -250,7 +250,19 @@ a {
}
// select dropdown border style
.ant-select-dropdown {
@apply border-1 border-gray-200
@apply border-1 border-gray-200;
.rc-virtual-list-scrollbar {
@apply !w-1;
}
.rc-virtual-list-scrollbar-thumb{
@apply !bg-gray-200;
&:hover{
@apply !bg-gray-300;
}
}
}
// menu item styling
@ -533,6 +545,13 @@ a {
@apply bg-gray-300 bg-opacity-20;
}
.ant-select-item-option:hover{
@apply !bg-gray-100;
}
.ant-select-item-option-selected{
@apply !bg-white;
}
/* Hide the element with id nc-selected-item-icon */
.ant-select-selection-item #nc-selected-item-icon {
@apply hidden;
@ -594,7 +613,7 @@ a {
}
.nc-click-transition {
@apply transform transition-transform transition-color !text-gray-400 !hover:(scale-130 !text-gray-500) !active:(scale-100 !text-gray-500);
@apply transform transition-transform transition-colors !text-gray-400 !hover:(scale-130 !text-gray-500) !active:(scale-100 !text-gray-500);
}
.nc-click-transition-1 {
@ -702,7 +721,7 @@ input[type='number'] {
}
.nc-sidebar-node-btn:not(.nc-sidebar-expand) {
@apply !xs:(hidden)
@apply !xs:(hidden);
}
}
@ -715,13 +734,18 @@ input[type='number'] {
@apply xs:(opacity-100 hover:bg-gray-50);
.nc-icon {
@apply xs:(visible opacity-100 !text-gray-500)
@apply xs:(visible opacity-100 !text-gray-500);
}
}
.ant-message-notice-content {
@apply !rounded-md;
.ant-message-custom-content{
@apply flex items-center
@apply flex items-center;
}
}
}
svg.nc-cell-icon, svg.nc-virtual-cell-icon {
@apply w-1em h-1em flex-none;
font-size: 1rem;
}

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

@ -1,11 +1,11 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
declare module 'vue' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
AAutoComplete: typeof import('ant-design-vue/es')['AutoComplete']
@ -97,6 +97,7 @@ declare module '@vue/runtime-core' {
MaterialSymbolsLockOutline: typeof import('~icons/material-symbols/lock-outline')['default']
MaterialSymbolsPublic: typeof import('~icons/material-symbols/public')['default']
MaterialSymbolsRocketLaunchOutline: typeof import('~icons/material-symbols/rocket-launch-outline')['default']
MaterialSymbolsSearch: typeof import('~icons/material-symbols/search')['default']
MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default']
MaterialSymbolsVisibility: typeof import('~icons/material-symbols/visibility')['default']
@ -120,6 +121,7 @@ declare module '@vue/runtime-core' {
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiChevronUp: typeof import('~icons/mdi/chevron-up')['default']
MdiCircleMedium: typeof import('~icons/mdi/circle-medium')['default']
MdiClockOutline: typeof import('~icons/mdi/clock-outline')['default']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCodeTags: typeof import('~icons/mdi/code-tags')['default']
MdiContentCopy: typeof import('~icons/mdi/content-copy')['default']
@ -131,6 +133,7 @@ declare module '@vue/runtime-core' {
MdiEye: typeof import('~icons/mdi/eye')['default']
MdiFileDocumentMultipleOutline: typeof import('~icons/mdi/file-document-multiple-outline')['default']
MdiFileDocumentOutline: typeof import('~icons/mdi/file-document-outline')['default']
MdiFileOutline: typeof import('~icons/mdi/file-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
MdiFormatBold: typeof import('~icons/mdi/format-bold')['default']
MdiFormatItalic: typeof import('~icons/mdi/format-italic')['default']

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

@ -45,6 +45,8 @@ const pagination = reactive({
pageSize: 10,
})
const isLoadingAllTokens = ref(true)
const setDefaultTokenName = () => {
selectedTokenData.value.description = extractNextDefaultName(
[...allTokens.value.map((el) => el?.description || '')],
@ -94,7 +96,7 @@ const updateAllTokens = (type: 'delete' | 'add', token: IApiTokenInfo) => {
setDefaultTokenName()
}
const loadTokens = async (page = currentPage.value, limit = currentLimit.value) => {
const loadTokens = async (page = currentPage.value, limit = currentLimit.value, hideShowNewToken = false) => {
currentPage.value = page
try {
const response: any = await api.orgTokens.list({
@ -103,18 +105,30 @@ const loadTokens = async (page = currentPage.value, limit = currentLimit.value)
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
},
} as RequestParams)
if (!response) return
if (!response) {
isLoadingAllTokens.value = false
return
}
pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10
tokens.value = response.list as IApiTokenInfo[]
if (hideShowNewToken) {
showNewTokenModal.value = false
selectedTokenData.value = {}
}
if (!allTokens.value.length) {
await loadAllTokens(pagination.total)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
if (isLoadingAllTokens.value) {
isLoadingAllTokens.value = false
}
}
}
@ -159,11 +173,10 @@ const generateToken = async () => {
if (!isValidTokenName.value) return
try {
const token = await api.orgTokens.create(selectedTokenData.value)
showNewTokenModal.value = false
// Token generated successfully
// message.success(t('msg.success.tokenGenerated'))
selectedTokenData.value = {}
await loadTokens()
await loadTokens(currentPage.value, currentLimit.value, true)
updateAllTokens('add', token as IApiTokenInfo)
} catch (e: any) {
@ -216,7 +229,7 @@ const handleCancel = () => {
<div class="max-w-202 mx-auto px-4 h-full" data-testid="nc-token-list">
<div class="py-2 flex gap-4 items-baseline justify-between">
<h6 class="text-2xl text-left font-bold" data-rec="true">{{ $t('title.apiTokens') }}</h6>
<NcTooltip :disabled="!(isEeUI && tokens.length)">
<NcTooltip v-if="tokens.length" :disabled="!(isEeUI && tokens.length)">
<template #title>{{ $t('labels.tokenLimit') }}</template>
<NcButton
:disabled="showNewTokenModal || (isEeUI && tokens.length)"
@ -237,7 +250,7 @@ const handleCancel = () => {
</NcTooltip>
</div>
<span data-rec="true">{{ $t('msg.apiTokenCreate') }}</span>
<div class="mt-5 h-[calc(100%-13rem)]">
<div v-if="!isLoadingAllTokens && (tokens.length || showNewTokenModal)" class="mt-5 h-[calc(100%-13rem)]">
<div class="h-full w-full !overflow-hidden rounded-md">
<div class="flex w-full pl-5 bg-gray-50 border-1 rounded-t-md">
<span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9" data-rec="true">{{ $t('title.tokenName') }}</span>
@ -268,6 +281,7 @@ const handleCancel = () => {
class="!rounded-lg !py-1"
placeholder="Token Name"
data-testid="nc-token-input"
:disabled="isLoading"
@press-enter="generateToken"
/>
<span v-if="!isValidTokenName" class="text-red-500 text-xs font-light mt-1.5 ml-1" data-rec="true"
@ -278,13 +292,7 @@ const handleCancel = () => {
<NcButton v-if="!isLoading" type="secondary" size="small" @click="handleCancel">
{{ $t('general.cancel') }}
</NcButton>
<NcButton
type="primary"
size="sm"
:is-loading="isLoading"
data-testid="nc-token-save-btn"
@click="generateToken"
>
<NcButton type="primary" size="sm" :loading="isLoading" data-testid="nc-token-save-btn" @click="generateToken">
{{ $t('general.save') }}
</NcButton>
</div>
@ -329,15 +337,15 @@ const handleCancel = () => {
@click="hideOrShowToken(el.token as string)"
/>
</NcTooltip>
<NcTooltip placement="top" class="h-4">
<NcTooltip placement="top">
<template #title>{{ $t('general.copy') }}</template>
<component
:is="iconMap.copy"
class="hover::cursor-pointer w-4 h-4 text-gray-600 mt-0.25"
class="hover::cursor-pointer w-4 h-4 text-gray-600"
@click="copyToken(el.token)"
/>
</NcTooltip>
<NcTooltip placement="top" class="mb-0.5">
<NcTooltip placement="top">
<template #title>{{ $t('general.delete') }}</template>
<component
:is="iconMap.delete"
@ -351,6 +359,25 @@ const handleCancel = () => {
</div>
</div>
</div>
<div
v-else-if="!isLoadingAllTokens && !tokens.length && !showNewTokenModal"
class="max-w-[40rem] border px-3 py-6 flex flex-col items-center justify-center gap-6 text-center"
>
<img src="~assets/img/placeholder/api-tokens.png" class="!w-[22rem] flex-none" />
<div class="text-2xl text-gray-800 font-bold">{{ $t('placeholder.noTokenCreated') }}</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.noTokenCreatedLabel') }}
</div>
<NcButton class="!rounded-lg !py-3 !h-10" data-testid="nc-token-create" type="primary" @click="showNewTokenModal = true">
<span class="hidden md:block" data-rec="true">
{{ $t('title.createNewToken') }}
</span>
<span class="flex items-center justify-center md:hidden" data-rec="true">
<component :is="iconMap.plus" />
</span>
</NcButton>
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-5">
<a-pagination

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

@ -203,13 +203,13 @@ const openDeleteModal = (user: UserType) => {
data-rec="true"
>
<span>
{{ $t('labels.email') }}
{{ $t('objects.users') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.email" field="email" :handle-user-sort="saveOrUpdate" />
</div>
<div class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start flex items-center space-x-2" data-rec="true">
<span>
{{ $t('objects.role') }}
{{ $t('general.access') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
@ -235,7 +235,15 @@ const openDeleteModal = (user: UserType) => {
}"
>
<div class="text-3.5 text-start w-2/3 pl-5 flex items-center">
<GeneralTruncateText length="29">
<NcTooltip v-if="el.display_name">
<template #title>
{{ el.email }}
</template>
<GeneralTruncateText length="29">
{{ el.display_name }}
</GeneralTruncateText>
</NcTooltip>
<GeneralTruncateText v-else length="29">
{{ el.email }}
</GeneralTruncateText>
</div>
@ -244,7 +252,7 @@ const openDeleteModal = (user: UserType) => {
{{ $t('labels.superAdmin') }}
</div>
<NcSelect
v-else
v-else-if="el.id !== loggedInUser?.id"
v-model:value="el.roles"
class="w-55 nc-user-roles"
:dropdown-match-select-width="false"
@ -265,9 +273,9 @@ const openDeleteModal = (user: UserType) => {
class="w-4 h-4 text-primary"
/>
</div>
<span class="text-gray-500 text-xs whitespace-normal" data-rec="true">
<div class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgCreator') }}
</span>
</div>
</a-select-option>
<a-select-option
@ -284,11 +292,14 @@ const openDeleteModal = (user: UserType) => {
class="w-4 h-4 text-primary"
/>
</div>
<span class="text-gray-500 text-xs whitespace-normal" data-rec="true">
<div class="text-gray-500 text-xs whitespace-normal" data-rec="true">
{{ $t('msg.info.roles.orgViewer') }}
</span>
</div>
</a-select-option>
</NcSelect>
<div v-else class="font-weight-bold" data-rec="true">
{{ $t(`objects.roleType.orgLevelCreator`) }}
</div>
</div>
<span class="w-26 flex items-center justify-end mr-4">
<div
@ -334,6 +345,18 @@ const openDeleteModal = (user: UserType) => {
</div>
</span>
</div>
<div
v-if="sortedUsers.length === 1"
class="user pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center border-b-1 border-l-1 border-r-1"
>
<div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }}
</div>
<div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }}
</div>
<img src="~assets/img/placeholder/invite-team.png" class="!w-[30rem] flex-none" />
</div>
</section>
</div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-4">

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

@ -90,12 +90,13 @@ useSelectedCellKeyupListener(active, (e) => {
'w-full justify-center': !isForm && !isGallery && !isExpandedFormOpen,
'nc-cell-hover-show': !vModel && !readOnly,
'opacity-0': readOnly && !vModel,
'pointer-events-none': readOnly,
}"
:style="{
height:
isForm || isExpandedFormOpen || isGallery || isEditColumnMenu ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
}"
tabindex="0"
:tabindex="readOnly ? -1 : 0"
@click="onClick(false, $event)"
@keydown.enter.stop="onClick(true, $event)"
>

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

@ -6,6 +6,7 @@ import {
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
ReadonlyInj,
computed,
inject,
parseProp,
@ -28,6 +29,8 @@ const editEnabled = inject(EditModeInj)!
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const _vModel = useVModel(props, 'modelValue', emit)
const vModel = computed({
@ -80,6 +83,20 @@ const submitCurrency = () => {
editEnabled.value = false
}
const onBlur = () => {
// triggered by events like focus-out / pressing enter
// for non-firefox browsers only
submitCurrency()
}
const onKeydownEnter = () => {
// onBlur is never executed for firefox & safari
// we use keydown.enter to trigger submitCurrency
if (/(Firefox|Safari)/.test(navigator.userAgent)) {
submitCurrency()
}
}
onMounted(() => {
lastSaved.value = vModel.value
})
@ -87,14 +104,14 @@ onMounted(() => {
<template>
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
type="number"
class="w-full h-full text-sm border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field w-full h-full text-sm border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="submitCurrency"
@blur="onBlur"
@keydown.enter="onKeydownEnter"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@ -105,10 +122,10 @@ onMounted(() => {
@contextmenu.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<!-- only show the numeric value as previously string value was accepted -->
<span v-else-if="!isNaN(vModel)">{{ currency }}</span>
<span v-else-if="!isNaN(vModel)" class="nc-cell-field">{{ currency }}</span>
<!-- possibly unexpected string / null with showNull == false -->
<span v-else />

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

@ -7,7 +7,6 @@ import {
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
ReadonlyInj,
computed,
inject,
@ -42,8 +41,6 @@ const readOnly = inject(ReadonlyInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
@ -240,11 +237,12 @@ const clickHandler = () => {
<template>
<a-date-picker
v-model:value="localState"
:disabled="readOnly"
:picker="picker"
:tabindex="0"
:bordered="false"
class="!w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
:format="dateFormat"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"

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

@ -6,7 +6,6 @@ import {
CellClickHookInj,
ColumnInj,
EditColumnInj,
IsExpandedFormOpenInj,
ReadonlyInj,
inject,
isDrawerOrModalExist,
@ -40,8 +39,6 @@ const { t } = useI18n()
const isEditColumn = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const column = inject(ColumnInj)!
const isDateInvalid = ref(false)
@ -297,8 +294,8 @@ const isColDisabled = computed(() => {
:disabled="isColDisabled"
:show-time="true"
:bordered="false"
class="!w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
:format="dateTimeFormat"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"

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

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, inject, useVModel } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, ReadonlyInj, inject, useVModel } from '#imports'
interface Props {
// when we set a number, then it is number type
@ -25,6 +25,8 @@ const column = inject(ColumnInj, null)!
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const domRef = ref<HTMLElement>()
const meta = computed(() => {
@ -94,11 +96,10 @@ watch(isExpandedFormOpen, () => {
<template>
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none py-1 border-none rounded-md w-full h-full !text-sm"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full !text-sm"
type="number"
:step="precision"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@ -112,8 +113,8 @@ watch(isExpandedFormOpen, () => {
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else class="text-sm">{{ displayValue }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else class="nc-cell-field text-sm">{{ displayValue }}</span>
</template>
<style scoped lang="scss">

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

@ -6,6 +6,7 @@ import {
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
ReadonlyInj,
computed,
convertDurationToSeconds,
convertMS2Duration,
@ -32,14 +33,16 @@ const column = inject(ColumnInj)
const editEnabled = inject(EditModeInj)
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const showWarningMessage = ref(false)
const durationInMS = ref(0)
const isEdited = ref(false)
const isEditColumn = inject(EditColumnInj, ref(false))
const durationType = computed(() => parseProp(column?.value?.meta)?.duration || 0)
const durationPlaceholder = computed(() =>
@ -93,11 +96,10 @@ const focus: VNodeRef = (el) =>
<template>
<div class="duration-cell-wrapper">
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="localState"
class="w-full !border-none !outline-none py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field w-full !border-none !outline-none py-1"
:placeholder="durationPlaceholder"
@blur="submitDuration"
@keypress="checkDurationFormat($event)"
@ -111,11 +113,11 @@ const focus: VNodeRef = (el) =>
@mousedown.stop
/>
<span v-else-if="modelValue === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else-if="modelValue === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else> {{ localState }}</span>
<span v-else class="nc-cell-field"> {{ localState }}</span>
<div v-if="showWarningMessage && showValidationError" class="duration-warning">
<div v-if="showWarningMessage && showValidationError" class="nc-cell-field duration-warning">
{{ $t('msg.plsEnterANumber') }}
</div>
</div>

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

@ -6,6 +6,7 @@ import {
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
useI18n,
@ -30,13 +31,15 @@ const editEnabled = inject(EditModeInj)!
const column = inject(ColumnInj)!
// Used in the logic of when to display error since we are not storing the email if it's not valid
const localState = ref(value)
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
// Used in the logic of when to display error since we are not storing the email if it's not valid
const localState = ref(value)
const vModel = computed({
get: () => value,
set: (val) => {
@ -71,11 +74,10 @@ watch(
<template>
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="w-full outline-none text-sm py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field w-full outline-none text-sm py-1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@keydown.down.stop
@ -87,17 +89,18 @@ watch(
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<nuxt-link
v-else-if="validEmail"
no-ref
class="text-sm underline hover:opacity-75 inline-block"
class="py-1 text-sm underline hover:opacity-75 inline-block"
:href="`mailto:${vModel}`"
target="_blank"
:tabindex="readOnly ? -1 : 0"
>
<LazyCellClampedText :value="vModel" :lines="rowHeight" />
<LazyCellClampedText :value="vModel" :lines="rowHeight" class="nc-cell-field" />
</nuxt-link>
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" />
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" class="nc-cell-field" />
</template>

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

@ -51,7 +51,7 @@ const focus: VNodeRef = (el) =>
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none px-1 border-none w-full h-full text-sm"
class="nc-cell-field outline-none px-1 border-none w-full h-full text-sm"
type="number"
step="0.1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@ -64,8 +64,8 @@ const focus: VNodeRef = (el) =>
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else class="text-sm">{{ vModel }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else class="nc-cell-field text-sm">{{ vModel }}</span>
</template>
<style scoped lang="scss">

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

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { GeoLocationType } from 'nocodb-sdk'
import { Modal as AModal, IsExpandedFormOpenInj, iconMap, latLongToJoinedString, useVModel } from '#imports'
import { Modal as AModal, iconMap, latLongToJoinedString, useVModel } from '#imports'
interface Props {
modelValue?: string | null
@ -16,8 +16,6 @@ const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'modelValue', emits)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const isExpanded = ref(false)
const isLoading = ref(false)
@ -105,10 +103,7 @@ const openInOSM = () => {
v-else
data-testid="nc-geo-data-lat-long-set"
tabindex="0"
class="h-full w-full flex items-center py-1 focus-visible:!outline-none focus:!outline-none"
:class="{
'px-2': isExpandedFormOpen,
}"
class="nc-cell-field h-full w-full flex items-center py-1 focus-visible:!outline-none focus:!outline-none"
>
{{ latLongStr }}
</div>

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, inject, useVModel } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, ReadonlyInj, inject, useVModel } from '#imports'
interface Props {
// when we set a number, then it is number type
@ -23,6 +23,8 @@ const editEnabled = inject(EditModeInj)
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const _vModel = useVModel(props, 'modelValue', emits)
const displayValue = computed(() => {
@ -85,11 +87,10 @@ function onKeyDown(e: any) {
<template>
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none py-1 border-none w-full h-full text-sm"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field outline-none py-1 border-none w-full h-full text-sm"
type="number"
style="letter-spacing: 0.06rem"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@ -103,8 +104,8 @@ function onKeyDown(e: any) {
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else class="text-sm">{{ displayValue }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else class="nc-cell-field text-sm">{{ displayValue }}</span>
</template>
<style scoped lang="scss">

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

@ -35,7 +35,7 @@ const active = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const readonly = inject(ReadonlyInj)
const readOnly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
@ -158,7 +158,7 @@ watch(isExpanded, () => {
:footer="null"
:wrap-class-name="isExpanded ? '!z-1051' : null"
>
<div v-if="editEnabled && !readonly" class="flex flex-col w-full" @mousedown.stop @mouseup.stop @click.stop>
<div v-if="editEnabled && !readOnly" class="flex flex-col w-full" @mousedown.stop @mouseup.stop @click.stop>
<div class="flex flex-row justify-between pt-1 pb-2 nc-json-action" @mousedown.stop>
<a-button type="text" size="small" @click="isExpanded = !isExpanded">
<CilFullscreenExit v-if="isExpanded" class="h-2.5" />
@ -185,16 +185,17 @@ watch(isExpanded, () => {
:hide-minimap="true"
:disable-deep-compare="true"
@update:model-value="localValue = $event"
@keydown.enter.stop
/>
<span v-if="error" class="text-xs w-full py-1 text-red-500">
<span v-if="error" class="nc-cell-field text-xs w-full py-1 text-red-500">
{{ error.toString() }}
</span>
</div>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" />
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" class="nc-cell-field" />
</component>
</template>

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

@ -8,7 +8,6 @@ import {
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsKanbanInj,
ReadonlyInj,
RowHeightInj,
@ -64,8 +63,6 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const selectedIds = ref<string[]>([])
const aselect = ref<typeof AntSelect>()
@ -355,8 +352,8 @@ const onFocus = () => {
<template>
<div
class="nc-multi-select h-full w-full flex items-center"
:class="{ 'read-only': readOnly, 'px-2': isExpandedFormOpen }"
class="nc-cell-field nc-multi-select h-full w-full flex items-center"
:class="{ 'read-only': readOnly }"
@click="toggleMenu"
>
<div

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { VNodeRef } from '@vue/runtime-core'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, inject, useVModel } from '#imports'
import { EditColumnInj, EditModeInj, IsExpandedFormOpenInj, IsFormInj, ReadonlyInj, inject, useVModel } from '#imports'
interface Props {
modelValue?: number | string | null
@ -14,10 +14,12 @@ const { showNull } = useGlobal()
const column = inject(ColumnInj)!
const editEnabled = inject(EditModeInj)
const editEnabled = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const _vModel = useVModel(props, 'modelValue', emits)
const wrapperRef = ref<HTMLElement>()
@ -118,18 +120,18 @@ const onTabPress = (e: KeyboardEvent) => {
<template>
<div
ref="wrapperRef"
tabindex="0"
:tabindex="readOnly ? -1 : 0"
class="nc-filter-value-select w-full focus:outline-transparent"
:class="readOnly ? 'cursor-not-allowed pointer-events-none' : ''"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
@focus="onWrapperFocus"
>
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled && (isExpandedFormOpen ? expandedEditEnabled : true)"
:ref="focus"
v-model="vModel"
class="w-full !text-sm !border-none !outline-none focus:ring-0 text-base py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field w-full !text-sm !border-none !outline-none focus:ring-0 text-base py-1"
type="number"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur"
@ -143,7 +145,7 @@ const onTabPress = (e: KeyboardEvent) => {
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<div v-else-if="percentMeta.is_progress === true && vModel !== null && vModel !== undefined" class="px-2">
<a-progress
:percent="Number(parseFloat(vModel.toString()).toFixed(2))"
@ -155,7 +157,7 @@ const onTabPress = (e: KeyboardEvent) => {
/>
</div>
<!-- nbsp to keep height even if vModel is zero length -->
<span v-else>{{ vModel }}&nbsp;</span>
<span v-else class="nc-cell-field">{{ vModel }}&nbsp;</span>
</div>
</template>

26
packages/nc-gui/components/cell/PhoneNumber.vue

@ -17,17 +17,19 @@ const { showNull } = useGlobal()
const { t } = useI18n()
const editEnabled = inject(EditModeInj)!
const editEnabled = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const column = inject(ColumnInj)!
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
// Used in the logic of when to display error since we are not storing the phone if it's not valid
const localState = ref(value)
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const vModel = computed({
get: () => value,
set: (val) => {
@ -38,7 +40,7 @@ const vModel = computed({
},
})
const validEmail = computed(() => vModel.value && isMobilePhone(vModel.value))
const validPhoneNumber = computed(() => vModel.value && isMobilePhone(vModel.value))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
@ -62,11 +64,10 @@ watch(
<template>
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="w-full outline-none text-sm py-1"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field w-full outline-none text-sm py-1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@keydown.down.stop
@ -78,17 +79,18 @@ watch(
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<a
v-else-if="validEmail"
class="text-sm underline hover:opacity-75"
v-else-if="validPhoneNumber"
class="py-1 text-sm underline hover:opacity-75 inline-block"
:href="`tel:${vModel}`"
target="_blank"
rel="noopener noreferrer"
:tabindex="readOnly ? -1 : 0"
>
<LazyCellClampedText :value="vModel" :lines="rowHeight" />
<LazyCellClampedText :value="vModel" :lines="rowHeight" class="nc-cell-field" />
</a>
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" />
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" class="nc-cell-field" />
</template>

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

@ -3,6 +3,7 @@ import {
ActiveCellInj,
ColumnInj,
IsExpandedFormOpenInj,
ReadonlyInj,
computed,
inject,
parseProp,
@ -19,7 +20,7 @@ const emits = defineEmits(['update:modelValue'])
const column = inject(ColumnInj)!
const readonly = inject(ReadonlyInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
@ -73,8 +74,9 @@ watch(rateDomRef, () => {
<a-rate
ref="rateDomRef"
v-model:value="vModel"
:disabled="readonly"
:disabled="readOnly"
:count="ratingMeta.max"
:class="readOnly ? 'pointer-events-none' : ''"
:style="`color: ${ratingMeta.color}; padding: ${isExpandedFormOpen ? '0px 8px' : '0px 5px'};`"
@keydown="onKeyPress"
>

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

@ -9,9 +9,9 @@ defineProps<Props>()
provide(ReadonlyInj, ref(true))
provide(EditModeInj, ref(true))
provide(EditModeInj, ref(false))
provide(ActiveCellInj, ref(true))
provide(ActiveCellInj, ref(false))
</script>
<template>

6
packages/nc-gui/components/cell/ReadOnlyUser.vue

@ -9,13 +9,13 @@ defineProps<Props>()
provide(ReadonlyInj, ref(true))
provide(EditModeInj, ref(true))
provide(EditModeInj, ref(false))
provide(ActiveCellInj, ref(true))
provide(ActiveCellInj, ref(false))
</script>
<template>
<div class="relative">
<div class="relative w-full">
<LazyCellUser class="z-0" :model-value="modelValue" />
<div class="w-full h-full z-1 absolute top-0 left-0"></div>
</div>

102
packages/nc-gui/components/cell/RichText.vue

@ -8,10 +8,11 @@ import { generateJSON } from '@tiptap/html'
import Underline from '@tiptap/extension-underline'
import { TaskItem } from '@/helpers/dbTiptapExtensions/task-item'
import { Link } from '@/helpers/dbTiptapExtensions/links'
import { IsExpandedFormOpenInj, IsFormInj, ReadonlyInj, RowHeightInj } from '#imports'
const props = defineProps<{
value?: string | null
readonly?: boolean
readOnly?: boolean
syncValueChange?: boolean
showMenu?: boolean
fullMode?: boolean
@ -21,6 +22,12 @@ const emits = defineEmits(['update:value'])
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const rowHeight = inject(RowHeightInj, ref(1 as const))
const readOnlyCell = inject(ReadonlyInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', {
@ -116,7 +123,7 @@ const editor = useEditor({
vModel.value = markdown
},
editable: !props.readonly,
editable: !props.readOnly,
})
const setEditorContent = (contentMd: any, focusEndOfDoc?: boolean) => {
@ -167,13 +174,22 @@ watch(editorDom, () => {
<div
class="h-full focus:outline-none"
:class="{
'flex flex-col flex-grow nc-rich-text-full': props.fullMode,
'nc-rich-text-embed flex flex-col pl-1 w-full': !props.fullMode,
'flex flex-col flex-grow nc-rich-text-full': fullMode,
'nc-rich-text-embed flex flex-col pl-1 w-full': !fullMode,
'readonly': readOnly,
}"
tabindex="0"
:tabindex="readOnlyCell ? -1 : 0"
>
<div v-if="props.showMenu" class="absolute top-0 right-0.5">
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode />
<div
v-if="showMenu && !readOnly"
class="absolute top-0 right-0.5 xs:hidden"
:class="{
'max-w-[calc(100%_-_198px)] flex justify-end rounded-tr-2xl overflow-hidden': fullMode,
}"
>
<div class="nc-scrollbar-x-md">
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode />
</div>
</div>
<CellRichTextSelectedBubbleMenuPopup v-if="editor" :editor="editor" />
<CellRichTextLinkOptions v-if="editor" :editor="editor" />
@ -182,9 +198,11 @@ watch(editorDom, () => {
:editor="editor"
class="flex flex-col nc-textarea-rich-editor w-full"
:class="{
'ml-1 mt-2.5 flex-grow': props.fullMode,
'nc-scrollbar-md': (!props.fullMode && !props.readonly) || isExpandedFormOpen,
'mt-2.5 flex-grow': fullMode,
'nc-scrollbar-md': !fullMode || (!fullMode && isExpandedFormOpen),
'flex-grow': isExpandedFormOpen,
[`!overflow-hidden children:line-clamp-${rowHeight}`]:
!fullMode && readOnly && rowHeight && !isExpandedFormOpen && !isForm,
}"
/>
</div>
@ -205,22 +223,41 @@ watch(editorDom, () => {
.nc-rich-text-embed {
.ProseMirror {
@apply !border-transparent max-h-full;
min-height: 8rem;
}
&.readonly {
.nc-textarea-rich-editor {
.ProseMirror {
resize: none;
white-space: pre-line;
}
}
}
}
.nc-rich-text-full {
@apply px-1.75;
@apply px-3;
.ProseMirror {
@apply !p-2;
max-height: calc(min(60vh, 100rem));
min-height: 8rem;
@apply !p-2 h-[min(797px,100vh_-_170px)] w-[min(1256px,100vw_-_124px)];
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin !important;
resize: both;
min-height: 215px;
max-height: min(797px, calc(100vh - 170px));
min-width: 256px;
max-width: min(1256px, 100vw - 126px);
}
&.readonly {
.ProseMirror {
@apply bg-gray-50;
}
}
}
.nc-textarea-rich-editor {
.ProseMirror {
@apply flex-grow pt-1 border-1 border-gray-200 rounded-lg pr-1 mr-2;
@apply flex-grow pt-1 border-1 border-gray-200 rounded-lg;
> * {
@apply ml-1;
@ -332,39 +369,4 @@ watch(editorDom, () => {
height: fit-content;
}
}
.nc-rich-text-full {
.ProseMirror {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
-webkit-border-radius: 10px;
border-radius: 10px;
margin-top: 4px;
margin-bottom: 4px;
}
&::-webkit-scrollbar-track-piece {
width: 0px;
}
&::-webkit-scrollbar {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
-webkit-border-radius: 10px;
border-radius: 10px;
width: 4px;
@apply bg-gray-300;
}
&::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400;
}
}
}
</style>

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

@ -8,7 +8,6 @@ import {
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsKanbanInj,
ReadonlyInj,
@ -48,8 +47,6 @@ const activeCell = inject(ActiveCellInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
// use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value || isForm.value)
@ -258,7 +255,7 @@ const handleClose = (e: MouseEvent) => {
useEventListener(document, 'click', handleClose, true)
const selectedOpt = computed(() => {
return options.value.find((o) => o.value === vModel.value || o.value === vModel.value?.trim())
return options.value.find((o) => o.value === vModel.value || o.value === vModel.value?.toString()?.trim())
})
const onFocus = () => {
@ -274,8 +271,8 @@ const onFocus = () => {
<template>
<div
class="h-full w-full flex items-center nc-single-select focus:outline-transparent"
:class="{ 'read-only': readOnly, 'px-2': isExpandedFormOpen }"
class="nc-cell-field h-full w-full flex items-center nc-single-select focus:outline-transparent"
:class="{ 'read-only': readOnly }"
@click="toggleMenu"
@keydown.enter.stop.prevent="toggleMenu"
>

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

@ -28,7 +28,7 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const readonly = inject(ReadonlyInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const vModel = useVModel(props, 'modelValue', emits)
@ -42,11 +42,10 @@ const focus: VNodeRef = (el) =>
<template>
<input
v-if="!readonly && editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="h-full w-full outline-none py-1 bg-transparent"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field h-full w-full outline-none py-1 bg-transparent"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@keydown.down.stop
@ -58,7 +57,7 @@ const focus: VNodeRef = (el) =>
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText v-else :value="vModel" :lines="rowHeight" />
<LazyCellClampedText v-else class="nc-cell-field" :value="vModel" :lines="rowHeight" :style="{ 'word-break': 'break-word' }" />
</template>

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

@ -48,15 +48,20 @@ const position = ref<
left: number
}
| undefined
>({
top: 200,
left: 600,
})
>()
const mousePosition = ref<
| {
top: number
left: number
}
| undefined
>()
const isDragging = ref(false)
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && isForm.value && (el as HTMLTextAreaElement)?.focus()
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLTextAreaElement)?.focus()
const height = computed(() => {
if (isExpandedFormOpen.value) return 36 * 4
@ -108,13 +113,6 @@ const isRichMode = computed(() => {
const onExpand = () => {
isVisible.value = true
const { top, left } = inputWrapperRef.value?.getBoundingClientRect() ?? { top: 0, left: 0 }
position.value = {
top: top + 42,
left,
}
}
const onMouseMove = (e: MouseEvent) => {
@ -123,8 +121,11 @@ const onMouseMove = (e: MouseEvent) => {
e.stopPropagation()
position.value = {
top: e.clientY - 30,
left: e.clientX - 120,
top: e.clientY - (mousePosition.value?.top || 0) > 0 ? e.clientY - (mousePosition.value?.top || 0) : position.value?.top || 0,
left:
e.clientX - (mousePosition.value?.left || 0) > -16
? e.clientX - (mousePosition.value?.left || 0)
: position.value?.left || 0,
}
}
@ -135,27 +136,36 @@ const onMouseUp = (e: MouseEvent) => {
isDragging.value = false
position.value = undefined
mousePosition.value = undefined
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
watch(position, () => {
const dom = document.querySelector('.nc-textarea-dropdown-active') as HTMLElement
if (!dom) return
if (!position.value) return
// Set left and top of dom
setTimeout(() => {
if (!position.value) return
watch(
position,
() => {
const dom = document.querySelector('.nc-long-text-expanded-modal .ant-modal-content') as HTMLElement
if (!dom || !position.value) return
// Set left and top of dom
dom.style.transform = 'none'
dom.style.left = `${position.value.left}px`
dom.style.top = `${position.value.top}px`
}, 1)
})
},
{ deep: true },
)
const dragStart = (e: MouseEvent) => {
if (isEditColumn.value) return
const dom = document.querySelector('.nc-long-text-expanded-modal .ant-modal-content') as HTMLElement
mousePosition.value = {
top: e.clientY - dom.getBoundingClientRect().top,
left: e.clientX - dom.getBoundingClientRect().left + 16,
}
const dragStart = () => {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
@ -167,41 +177,56 @@ watch(editEnabled, () => {
isVisible.value = true
}
})
const stopPropagation = (event: MouseEvent) => {
event.stopPropagation()
}
watch(inputWrapperRef, () => {
if (!isEditColumn.value) return
// stop event propogation in edit column
const modal = document.querySelector('.nc-long-text-expanded-modal') as HTMLElement
if (isVisible.value && modal?.parentElement) {
modal.parentElement.addEventListener('click', stopPropagation)
modal.parentElement.addEventListener('mousedown', stopPropagation)
modal.parentElement.addEventListener('mouseup', stopPropagation)
} else if (modal?.parentElement) {
modal.parentElement.removeEventListener('click', stopPropagation)
modal.parentElement.removeEventListener('mousedown', stopPropagation)
modal.parentElement.removeEventListener('mouseup', stopPropagation)
}
})
</script>
<template>
<NcDropdown
v-model:visible="isVisible"
class="overflow-hidden group"
:trigger="[]"
placement="bottomLeft"
:overlay-class-name="isVisible ? 'nc-textarea-dropdown-active' : undefined"
>
<div>
<div
class="flex flex-row pt-0.5 w-full long-text-wrapper"
:class="{
'min-h-10': rowHeight !== 1 || isExpandedFormOpen,
'min-h-9': rowHeight === 1 && !isExpandedFormOpen,
'h-full': isForm,
'h-full w-full': isForm,
}"
>
<div
v-if="isRichMode"
class="w-full cursor-pointer"
class="w-full cursor-pointer nc-readonly-rich-text-wrapper"
:style="{
maxHeight: `${height}px !important`,
minHeight: `${height}px !important`,
maxHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
minHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
}"
@dblclick="onExpand"
@keydown.enter="onExpand"
>
<LazyCellRichText v-model:value="vModel" sync-value-change readonly />
<LazyCellRichText v-model:value="vModel" sync-value-change read-only />
</div>
<textarea
v-else-if="editEnabled && !isVisible"
:ref="focus"
v-model="vModel"
rows="4"
:rows="isForm ? 5 : 4"
class="h-full w-full outline-none border-none nc-scrollbar-lg"
:class="{
'p-2': editEnabled,
@ -209,9 +234,10 @@ watch(editEnabled, () => {
'px-2': isExpandedFormOpen,
}"
:style="{
minHeight: `${height}px`,
minHeight: isForm ? '117px' : `${height}px`,
}"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:disabled="readOnly"
@blur="editEnabled = false"
@keydown.alt.enter.stop
@keydown.shift.enter.stop
@ -234,6 +260,7 @@ watch(editEnabled, () => {
:style="{
'word-break': 'break-word',
'white-space': 'pre-line',
'max-height': `${25 * (rowHeight || 1)}px`,
}"
@click="onTextClick"
/>
@ -243,8 +270,8 @@ watch(editEnabled, () => {
<NcTooltip
v-if="!isVisible"
placement="bottom"
class="!absolute right-0 hidden nc-text-area-expand-btn group-hover:block"
:class="isExpandedFormOpen || isForm || isRichMode ? 'top-1' : 'bottom-1'"
class="!absolute right-1 hidden nc-text-area-expand-btn group-hover:block z-3"
:class="isExpandedFormOpen || isForm ? 'top-1' : 'bottom-1'"
>
<template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">
@ -252,21 +279,31 @@ watch(editEnabled, () => {
</NcButton>
</NcTooltip>
</div>
<template #overlay>
<a-modal
v-if="isVisible"
v-model:visible="isVisible"
:closable="false"
:footer="null"
wrap-class-name="nc-long-text-expanded-modal"
:mask="true"
:mask-closable="false"
:mask-style="{ zIndex: 1051 }"
:z-index="1052"
>
<div
v-if="isVisible"
ref="inputWrapperRef"
class="flex flex-col min-w-200 min-h-70 py-3 expanded-cell-input relative"
class="flex flex-col py-3 w-full expanded-cell-input relative"
:class="{
'cursor-move': isDragging,
}"
@keydown.enter.stop
>
<div
v-if="column"
class="flex flex-row gap-x-1 items-center font-medium pl-3 pb-2.5 border-b-1 border-gray-100 cursor-move"
class="flex flex-row gap-x-1 items-center font-medium pl-3 pb-2.5 border-b-1 border-gray-100 overflow-hidden"
:class="{
'select-none': isDragging,
'cursor-move': !isEditColumn,
}"
@mousedown="dragStart"
>
@ -277,33 +314,60 @@ watch(editEnabled, () => {
</span>
</div>
</div>
<a-textarea
v-if="!isRichMode"
ref="inputRef"
v-model:value="vModel"
class="p-1 !pt-1 !pr-3 !border-0 !border-r-0 !focus:outline-transparent nc-scrollbar-md !text-black !cursor-text"
:placeholder="$t('activity.enterText')"
:bordered="false"
:auto-size="{ minRows: 20, maxRows: 20 }"
:disabled="readOnly"
@keydown.stop
@keydown.escape="isVisible = false"
/>
<LazyCellRichText v-else-if="isVisible" v-model:value="vModel" show-menu full-mode :read-only="readOnly" />
<div v-if="!isRichMode" class="p-3 pb-0 h-full">
<a-textarea
ref="inputRef"
v-model:value="vModel"
class="nc-text-area-expanded !py-1 !px-3 !text-black !cursor-text !min-h-[210px] !rounded-lg focus:border-brand-500 disabled:!bg-gray-50"
:placeholder="$t('activity.enterText')"
:style="{ resize: 'both' }"
:disabled="readOnly"
@keydown.escape="isVisible = false"
/>
</div>
<LazyCellRichText v-else v-model:value="vModel" show-menu full-mode :read-only="readOnly" />
</div>
</template>
</NcDropdown>
</a-modal>
</div>
</template>
<style lang="scss" scoped>
textarea:focus {
box-shadow: none;
}
.nc-text-area-expanded {
@apply h-[min(795px,100vh_-_170px)] w-[min(1256px,100vw_-_124px)];
max-height: min(795px, 100vh - 170px);
min-width: 256px;
max-width: min(1256px, 100vw - 126px);
scrollbar-width: thin !important;
&::-webkit-scrollbar-thumb {
@apply rounded-lg;
}
}
</style>
<style lang="scss">
.cell:hover .nc-text-area-expand-btn {
.cell:hover .nc-text-area-expand-btn,
.long-text-wrapper:hover .nc-text-area-expand-btn {
@apply !block cursor-pointer;
}
.nc-long-text-expanded-modal {
.ant-modal {
@apply !w-full h-full !top-0 !mx-auto !my-0;
.ant-modal-content {
@apply absolute w-[fit-content] min-h-70 min-w-70 !p-0 left-[50%] top-[50%];
/* Use 'transform' to center the div correctly */
transform: translate(-50%, -50%);
max-width: min(1280px, 100vw - 100px);
max-height: min(864px, 100vh - 100px);
}
}
}
</style>

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

@ -3,7 +3,6 @@ import dayjs from 'dayjs'
import {
ActiveCellInj,
EditColumnInj,
IsExpandedFormOpenInj,
ReadonlyInj,
inject,
onClickOutside,
@ -35,8 +34,6 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const column = inject(ColumnInj)!
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isTimeInvalid = ref(false)
const dateFormat = isMysql(column.value.source_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
@ -129,12 +126,13 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
<template>
<a-time-picker
v-model:value="localState"
:disabled="readOnly"
:show-time="true"
:bordered="false"
use12-hours
format="HH:mm"
class="!w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"

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

@ -8,6 +8,7 @@ import {
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
isValidURL,
@ -39,13 +40,15 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const disableOverlay = inject(CellUrlDisableOverlayInj, ref(false))
// Used in the logic of when to display error since we are not storing the url if it's not valid
const localState = ref(value)
const rowHeight = inject(RowHeightInj, ref(undefined))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
// Used in the logic of when to display error since we are not storing the url if it's not valid
const localState = ref(value)
const vModel = computed({
get: () => value,
set: (val) => {
@ -92,12 +95,11 @@ watch(
<template>
<div class="flex flex-row items-center justify-between w-full h-full">
<input
v-if="editEnabled"
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
:placeholder="isEditColumn ? $t('labels.enterDefaultUrlOptional') : ''"
class="outline-none text-sm w-full py-1 bg-transparent h-full"
:class="isExpandedFormOpen ? 'px-2' : 'px-0'"
class="nc-cell-field outline-none text-sm w-full py-1 bg-transparent h-full"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@ -108,31 +110,35 @@ watch(
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase"> {{ $t('general.null') }}</span>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase"> {{ $t('general.null') }}</span>
<nuxt-link
v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch
no-rel
class="z-3 text-sm underline hover:opacity-75"
class="py-1 z-3 text-sm underline hover:opacity-75"
:to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0"
>
<LazyCellClampedText :value="value" :lines="rowHeight" />
<LazyCellClampedText :value="value" :lines="rowHeight" class="nc-cell-field" />
</nuxt-link>
<nuxt-link
v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay"
no-prefetch
no-rel
class="z-3 w-full h-full text-center !no-underline hover:opacity-75"
class="py-1 z-3 w-full h-full text-center !no-underline hover:opacity-75"
:to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0"
>
<LazyCellClampedText :value="cellUrlOptions.overlay" :lines="rowHeight" />
<LazyCellClampedText :value="cellUrlOptions.overlay" :lines="rowHeight" class="nc-cell-field" />
</nuxt-link>
<span v-else class="w-9/10 overflow-ellipsis overflow-hidden"><LazyCellClampedText :value="value" :lines="rowHeight" /></span>
<span v-else class="w-9/10 overflow-ellipsis overflow-hidden"
><LazyCellClampedText :value="value" :lines="rowHeight" class="nc-cell-field"
/></span>
<div v-if="column.meta?.validate && !isValid && value?.length && !editEnabled" class="mr-1 w-1/10">
<a-tooltip placement="top">

44
packages/nc-gui/components/cell/User.vue

@ -9,7 +9,6 @@ import {
ColumnInj,
EditColumnInj,
EditModeInj,
IsExpandedFormOpenInj,
IsKanbanInj,
ReadonlyInj,
RowHeightInj,
@ -49,8 +48,6 @@ const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
@ -265,8 +262,8 @@ const filterOption = (input: string, option: any) => {
<template>
<div
class="nc-user-select h-full w-full flex items-center"
:class="{ 'read-only': readOnly, 'px-2': isExpandedFormOpen }"
class="nc-cell-field nc-user-select h-full w-full flex items-center"
:class="{ 'read-only': readOnly }"
@click="toggleMenu"
>
<div
@ -281,7 +278,7 @@ const filterOption = (input: string, option: any) => {
}"
>
<template v-for="selectedOpt of vModel" :key="selectedOpt.value">
<a-tag class="rounded-tag max-w-full" color="'#ccc'">
<a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
@ -289,8 +286,17 @@ const filterOption = (input: string, option: any) => {
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
>
<div class="flex-none">
<GeneralUserIcon
size="auto"
:name="!selectedOpt.label?.includes('@') ? selectedOpt.label.trim() : ''"
:email="selectedOpt.label"
class="!text-[0.65rem]"
/>
</div>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ selectedOpt.label }}
@ -349,13 +355,20 @@ const filterOption = (input: string, option: any) => {
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="flex items-center gap-2"
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
>
<GeneralUserIcon size="medium" :email="op.email" />
<div>
<GeneralUserIcon
size="auto"
:name="op.display_name?.trim() ? op.display_name?.trim() : ''"
:email="op.email"
class="!text-[0.65rem]"
/>
</div>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
{{ op.display_name?.length ? op.display_name : op.email }}
{{ op.display_name?.trim() || op.email }}
</template>
<span
class="text-ellipsis overflow-hidden"
@ -365,7 +378,7 @@ const filterOption = (input: string, option: any) => {
display: 'inline',
}"
>
{{ op.display_name?.length ? op.display_name : op.email }}
{{ op.display_name?.trim() || op.email }}
</span>
</NcTooltip>
</span>
@ -376,7 +389,7 @@ const filterOption = (input: string, option: any) => {
<template #tagRender="{ label, value: val, onClose }">
<a-tag
v-if="options.find((el) => el.id === val)"
class="rounded-tag nc-selected-option"
class="rounded-tag nc-selected-option !pl-0"
:style="{ display: 'flex', alignItems: 'center' }"
color="'#ccc'"
:closable="editAllowed && ((vModel?.length ?? 0) > 1 || !column?.rqd)"
@ -394,8 +407,17 @@ const filterOption = (input: string, option: any) => {
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
>
<div>
<GeneralUserIcon
size="auto"
:name="!label?.includes('@') ? label.trim() : ''"
:email="label"
class="!text-[0.65rem]"
/>
</div>
{{ label }}
</span>
</a-tag>

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

@ -3,7 +3,6 @@ import dayjs from 'dayjs'
import {
ActiveCellInj,
EditColumnInj,
IsExpandedFormOpenInj,
ReadonlyInj,
computed,
inject,
@ -32,8 +31,6 @@ const editable = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isYearInvalid = ref(false)
const { t } = useI18n()
@ -119,8 +116,8 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
:tabindex="0"
picker="year"
:bordered="false"
class="!w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull, '!px-2': isExpandedFormOpen, '!px-0': !isExpandedFormOpen }"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
:placeholder="placeholder"
:allow-clear="(!readOnly && !localState && !isPk) || isEditColumn"
:input-read-only="true"

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

@ -158,8 +158,8 @@ const handleFileDelete = (i: number) => {
<a-tooltip v-if="isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<div class="nc-attachment-download group-hover:(opacity-100) mr-[35px]">
<component :is="iconMap.edit" @click.stop="renameFile(item, i)" />
<div class="nc-attachment-rename group-hover:(opacity-100) mr-[35px]">
<component :is="iconMap.rename" @click.stop="renameFile(item, i)" />
</div>
</a-tooltip>
@ -249,7 +249,8 @@ const handleFileDelete = (i: number) => {
}
}
.nc-attachment-download {
.nc-attachment-download,
.nc-attachment-rename {
@apply bg-white absolute bottom-2 right-2;
@apply transition-opacity duration-150 ease-in opacity-0 hover:ring;
@apply cursor-pointer rounded shadow flex items-center p-1 border-1;

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

@ -1,4 +1,6 @@
import type { AttachmentType } from 'nocodb-sdk'
import type { AttachmentReqType, AttachmentType } from 'nocodb-sdk'
import { populateUniqueFileName } from 'nocodb-sdk'
import DOMPurify from 'isomorphic-dompurify'
import RenameFile from './RenameFile.vue'
import {
ColumnInj,
@ -9,6 +11,7 @@ import {
NOCO,
ReadonlyInj,
computed,
extractImageSrcFromRawHtml,
inject,
isImage,
message,
@ -98,22 +101,27 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
/** save a file on select / drop, either locally (in-memory) or in the db */
async function onFileSelect(selectedFiles: FileList | File[]) {
if (!selectedFiles.length) return
async function onFileSelect(selectedFiles: FileList | File[], selectedFileUrls?: AttachmentReqType[]) {
if (!selectedFiles.length && !selectedFileUrls?.length) return
const attachmentMeta = {
...defaultAttachmentMeta,
...parseProp(column?.value?.meta),
}
const newAttachments = []
const newAttachments: AttachmentType[] = []
const files: File[] = []
for (const file of selectedFiles) {
const imageUrls: AttachmentReqType[] = []
for (const file of selectedFiles.length ? selectedFiles : selectedFileUrls || []) {
if (appInfo.value.ee) {
// verify number of files
if (visibleItems.value.length + selectedFiles.length > attachmentMeta.maxNumberOfAttachments) {
if (
visibleItems.value.length + (selectedFiles.length || selectedFileUrls?.length || 0) >
attachmentMeta.maxNumberOfAttachments
) {
message.error(
`You can only upload at most ${attachmentMeta.maxNumberOfAttachments} file${
attachmentMeta.maxNumberOfAttachments > 1 ? 's' : ''
@ -123,35 +131,46 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
// verify file size
if (file.size > attachmentMeta.maxAttachmentSize * 1024 * 1024) {
message.error(`The size of ${file.name} exceeds the maximum file size ${attachmentMeta.maxAttachmentSize} MB.`)
if (file?.size && file.size > attachmentMeta.maxAttachmentSize * 1024 * 1024) {
message.error(
`The size of ${(file as File)?.name || (file as AttachmentReqType)?.fileName} exceeds the maximum file size ${
attachmentMeta.maxAttachmentSize
} MB.`,
)
continue
}
// verify mime type
if (
!attachmentMeta.supportedAttachmentMimeTypes.includes('*') &&
!attachmentMeta.supportedAttachmentMimeTypes.includes(file.type) &&
!attachmentMeta.supportedAttachmentMimeTypes.includes(file.type.split('/')[0])
!attachmentMeta.supportedAttachmentMimeTypes.includes((file as File).type || (file as AttachmentReqType).mimetype) &&
!attachmentMeta.supportedAttachmentMimeTypes.includes(
((file as File)?.type || (file as AttachmentReqType).mimetype)?.split('/')[0],
)
) {
message.error(`${file.name} has the mime type ${file.type} which is not allowed in this column.`)
message.error(
`${(file as File)?.name || (file as AttachmentReqType)?.fileName} has the mime type ${
(file as File)?.type || (file as AttachmentReqType)?.mimetype
} which is not allowed in this column.`,
)
continue
}
}
// this prevent file with same names
const isFileNameAlreadyExist = attachments.value.some((el) => el.title === file.name)
if (isFileNameAlreadyExist) {
message.error(
t('labels.duplicateAttachment', {
filename: file.name,
}),
if (selectedFiles.length) {
files.push(file as File)
} else {
const fileName = populateUniqueFileName(
(file as AttachmentReqType).fileName ?? '',
[...attachments.value, ...imageUrls].map((fn) => fn?.title || fn?.fileName),
(file as File)?.type || (file as AttachmentReqType)?.mimetype || '',
)
return
imageUrls.push({ ...(file as AttachmentReqType), fileName, title: fileName })
}
files.push(file)
}
if (isPublic.value && isForm.value) {
if (files.length && isPublic.value && isForm.value) {
const newFiles = await Promise.all<AttachmentType>(
Array.from(files).map(
(file) =>
@ -168,7 +187,6 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
reader.onload = (e) => {
res.data = e.target?.result
resolve(res)
}
@ -185,21 +203,49 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
)
attachments.value = [...attachments.value, ...newFiles]
return updateModelValue(attachments.value)
} else if (isPublic.value && isForm.value) {
attachments.value = [...attachments.value, ...imageUrls]
return updateModelValue(attachments.value)
}
try {
const data = await api.storage.upload(
{
path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'),
},
{
files,
},
)
newAttachments.push(...data)
} catch (e: any) {
message.error(e.message || t('msg.error.internalError'))
if (selectedFiles.length) {
try {
const data = await api.storage.upload(
{
path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'),
},
{
files,
},
)
// add suffix in duplicate file title
for (const uploadedFile of data) {
newAttachments.push({
...uploadedFile,
title: populateUniqueFileName(
uploadedFile?.title,
[...attachments.value, ...newAttachments].map((fn) => fn?.title || fn?.fileName),
uploadedFile?.mimetype,
),
})
}
} catch (e: any) {
message.error(e.message || t('msg.error.internalError'))
}
} else if (imageUrls.length) {
try {
const data = await api.storage.uploadByUrl(
{
path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'),
},
imageUrls,
)
newAttachments.push(...data)
} catch (e: any) {
message.error(e.message || t('msg.error.internalError'))
}
}
updateModelValue(JSON.stringify([...attachments.value, ...newAttachments]))
@ -224,12 +270,40 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
/** save files on drop */
async function onDrop(droppedFiles: FileList | File[] | null) {
async function onDrop(droppedFiles: FileList | File[] | null, event: DragEvent) {
if (isReadonly.value) return
if (droppedFiles) {
// set files
await onFileSelect(droppedFiles)
} else {
event.preventDefault()
// Sanitize the dataTransfer HTML string
const sanitizedHtml = DOMPurify.sanitize(event.dataTransfer?.getData('text/html') ?? '') ?? ''
const imageUrl = extractImageSrcFromRawHtml(sanitizedHtml) ?? ''
if (!imageUrl) {
message.error(t('msg.error.draggedContentIsNotTypeOfImage'))
return
}
const imageData = (await getImageDataFromUrl(imageUrl)) as AttachmentReqType
if (imageData?.mimetype) {
await onFileSelect(
[],
[
{
...imageData,
url: imageUrl,
fileName: `image.${imageData?.mimetype?.split('/')[1]}`,
title: `image.${imageData?.mimetype?.split('/')[1]}`,
},
],
)
} else {
message.error(t('msg.error.fieldToParseImageData'))
}
}
}
@ -249,6 +323,27 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
}
async function getImageDataFromUrl(imageUrl: string) {
try {
const response = await fetch(imageUrl)
if (response.ok) {
if (response.headers.get('content-type')?.startsWith('image/')) {
return {
mimetype: response.headers.get('content-type') || undefined,
size: +(response.headers.get('content-length') || 0) || undefined,
} as { mimetype?: string; size?: number }
} else if (imageUrl.slice(imageUrl.lastIndexOf('.') + 1).toLowerCase().length) {
return {
mimetype: `image/${imageUrl.slice(imageUrl.lastIndexOf('.') + 1).toLowerCase()}`,
size: +(response.headers.get('content-length') || 0) || undefined,
} as { mimetype?: string; size?: number }
}
}
} catch (err) {
console.log(err)
}
}
const FileIcon = (icon: string) => {
switch (icon) {
case 'mdi-pdf-box':

61
packages/nc-gui/components/cmd-footer/index.vue

@ -0,0 +1,61 @@
<script setup lang="ts">
import type { CommandPaletteType } from '~/lib'
defineProps<{
activeCmd: CommandPaletteType
setActiveCmdView: (cmd: CommandPaletteType) => void
}>()
const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'Ctrl'
}
</script>
<template>
<div class="cmdk-footer absolute inset-x-0 bottom-0 !bg-white">
<div class="flex justify-center w-full py-2">
<div
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer"
:class="activeCmd === 'cmd-j' ? 'text-brand-500' : ''"
@click.stop="activeCmd !== 'cmd-j' ? setActiveCmdView('cmd-j') : () => undefined"
>
<MdiFileOutline class="h-4 w-4" />
Document
<span
class="text-sm px-1 rounded-md border-1"
:class="activeCmd === 'cmd-j' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'"
>
{{ renderCmdOrCtrlKey() }} + J
</span>
</div>
<div
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer"
:class="activeCmd === 'cmd-k' ? 'text-brand-500' : ''"
@click.stop="activeCmd !== 'cmd-k' ? setActiveCmdView('cmd-k') : () => undefined"
>
<MdiMapMarkerOutline class="h-4 w-4" />
Quick Navigation
<span
class="text-sm px-1 rounded-md border-1"
:class="activeCmd === 'cmd-k' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'"
>
{{ renderCmdOrCtrlKey() }} + K
</span>
</div>
<div
class="flex flex-grow-1 w-full text-sm items-center gap-2 justify-center cursor-pointer"
:class="activeCmd === 'cmd-l' ? 'text-brand-500' : ''"
@click.stop="activeCmd !== 'cmd-l' ? setActiveCmdView('cmd-l') : () => undefined"
>
<MdiClockOutline class="h-4 w-4" />
Recent
<span
class="text-sm px-1 rounded-md border-1"
:class="activeCmd === 'cmd-l' ? 'bg-brand-500 border-brand-500 text-white' : 'bg-gray-100 border-gray-300'"
>
{{ renderCmdOrCtrlKey() }} + L
</span>
</div>
</div>
</div>
</template>

37
packages/nc-gui/components/cmd-j/index.vue

@ -0,0 +1,37 @@
<script setup lang="ts">
import '~/assets/js/typesense-docsearch'
declare const docsearch: any
const modalEl = ref<HTMLElement | null>(null)
const { user } = useGlobal()
watch(user, () => {
window.doc_enabled = !!user.value
})
onMounted(() => {
docsearch({
container: '#searchbar',
typesenseCollectionName: 'nocodb-oss-docs-index',
typesenseServerConfig: {
nodes: [
{
host: 'rqf5uvajyeczwt3xp-1.a1.typesense.net',
port: 443,
protocol: 'https',
},
],
apiKey: 'lNKDTZdJrE76Sg8WEyeN9mXT29l1xq7Q',
},
typesenseSearchParameters: {
// Optional.
},
})
})
</script>
<template>
<div id="searchbar" :ref="modalEl" class="hidden"></div>
</template>
<style></style>

162
packages/nc-gui/components/cmd-k/command-score.ts

@ -0,0 +1,162 @@
// this is derived from https://github.com/pacocoursey/cmdk
// The scores are arranged so that a continuous match of characters will
// result in a total score of 1.
//
// The best case, this character is a match, and either this is the start
// of the string, or the previous character was also a match.
const SCORE_CONTINUE_MATCH = 1.9
// A new match at the start of a word scores better than a new match
// elsewhere as it's more likely that the user will type the starts
// of fragments.
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
// hyphens, etc.
const SCORE_SPACE_WORD_JUMP = 1.0
const SCORE_NON_SPACE_WORD_JUMP = 0.8
// Any other match isn't ideal, but we include it for completeness.
const SCORE_CHARACTER_JUMP = 0.2
// If the user transposed two letters, it should be significantly penalized.
//
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
const SCORE_TRANSPOSITION = 0.3
// The goodness of a match should decay slightly with each missing
// character.
//
// i.e. "bad" is more likely than "bard" when "bd" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 100 characters are inserted between matches.
const PENALTY_SKIPPED = 0.999
// The goodness of an exact-case match should be higher than a
// case-insensitive match by a small amount.
//
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 1000 characters are inserted between matches.
const PENALTY_CASE_MISMATCH = 0.999999
// Match higher for letters closer to the beginning of the word
// const PENALTY_DISTANCE_FROM_START = 0.9
// If the word has more characters than the user typed, it should
// be penalised slightly.
//
// i.e. "html" is more likely than "html5" if I type "html".
//
// However, it may well be the case that there's a sensible secondary
// ordering (like alphabetical) that it makes sense to rely on when
// there are many prefix matches, so we don't make the penalty increase
// with the number of tokens.
const PENALTY_NOT_COMPLETE = 0.98
const IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/
const COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g
const IS_SPACE_REGEXP = /[\s-]/
const COUNT_SPACE_REGEXP = /[\s-]/g
function commandScoreInner(
string: string,
abbreviation: string,
lowerString: string,
lowerAbbreviation: string,
stringIndex: number,
abbreviationIndex: number,
memoizedResults: { [x: string]: number },
) {
if (abbreviationIndex === abbreviation.length) {
if (stringIndex === string.length) {
return SCORE_CONTINUE_MATCH
}
return PENALTY_NOT_COMPLETE
}
const memoizeKey = `${stringIndex},${abbreviationIndex}`
if (memoizedResults[memoizeKey] !== undefined) {
return memoizedResults[memoizeKey]
}
const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex)
let index = lowerString.indexOf(abbreviationChar, stringIndex)
let highScore = 0
let score, transposedScore, wordBreaks, spaceBreaks
while (index >= 0) {
score = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 1,
memoizedResults,
)
if (score > highScore) {
if (index === stringIndex) {
score *= SCORE_CONTINUE_MATCH
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_NON_SPACE_WORD_JUMP
wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP)
if (wordBreaks && stringIndex > 0) {
score *= PENALTY_SKIPPED ** wordBreaks.length
}
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_SPACE_WORD_JUMP
spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP)
if (spaceBreaks && stringIndex > 0) {
score *= PENALTY_SKIPPED ** spaceBreaks.length
}
} else {
score *= SCORE_CHARACTER_JUMP
if (stringIndex > 0) {
score *= PENALTY_SKIPPED ** (index - stringIndex)
}
}
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
score *= PENALTY_CASE_MISMATCH
}
}
if (
(score < SCORE_TRANSPOSITION && lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
(lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex))
) {
transposedScore = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 2,
memoizedResults,
)
if (transposedScore * SCORE_TRANSPOSITION > score) {
score = transposedScore * SCORE_TRANSPOSITION
}
}
if (score > highScore) {
highScore = score
}
index = lowerString.indexOf(abbreviationChar, index + 1)
}
memoizedResults[memoizeKey] = highScore
return highScore
}
function formatInput(string: string) {
// convert all valid space characters to space so they match each other
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ')
}
export function commandScore(string: string, abbreviation: string): number {
/* NOTE:
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
*/
return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {})
}

622
packages/nc-gui/components/cmd-k/index.vue

@ -1,3 +1,623 @@
<script lang="ts" setup>
import { useMagicKeys, whenever } from '@vueuse/core'
// import { useNuxtApp } from '#app'
import { commandScore } from './command-score'
import type { ComputedRef, VNode } from '#imports'
import { iconMap, onClickOutside } from '#imports'
import type { CommandPaletteType } from '~/lib'
interface CmdAction {
id: string
title: string
hotkey?: string
parent?: string
handler?: Function
scopePayload?: any
icon?: VNode | string
keywords?: string[]
section?: string
is_default?: number | null
}
const props = defineProps<{
open: boolean
data: CmdAction[]
scope?: string
placeholder?: string
hotkey?: string
loadTemporaryScope?: (scope: { scope: string; data: any }) => void
setActiveCmdView: (cmd: CommandPaletteType) => void
}>()
const emits = defineEmits(['update:open', 'scope'])
const vOpen = useVModel(props, 'open', emits)
const { t } = useI18n()
const activeScope = ref('root')
const modalEl = ref<HTMLElement>()
const cmdInputEl = ref<HTMLInputElement>()
const cmdInput = ref('')
const { user } = useGlobal()
const selected = ref<string>()
const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(() => {
const rt: (CmdAction & { weight: number })[] = []
for (const el of props.data) {
rt.push({
...el,
title: el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title,
icon: el.section === 'Views' && el.is_default ? 'grid' : el.icon,
parent: el.parent || 'root',
weight: commandScore(
`${el.section}${el?.section === 'Views' && el?.is_default ? t('title.defaultView') : el.title}${el.keywords?.join()}`,
cmdInput.value,
),
})
}
return rt
})
const nestedScope = computed(() => {
const rt = []
let parent = activeScope.value
while (parent !== 'root') {
const parentEl = formattedData.value.find((el) => el.id === parent)
rt.push({
id: parent,
label: parentEl?.title,
icon: parentEl?.icon,
})
parent = parentEl?.parent || 'root'
}
return rt.reverse()
})
const isThereAnyActionInScope = (sc: string): boolean => {
return formattedData.value.some((el) => {
if (el.parent === sc) {
if (!el.handler) {
return isThereAnyActionInScope(el.id)
}
return true
}
return false
})
}
const getAvailableScopes = (sc: string) => {
const tempChildScopes = formattedData.value.filter((el) => el.parent === sc && !el.handler).map((el) => el.id)
for (const el of tempChildScopes) {
tempChildScopes.push(...getAvailableScopes(el))
}
return tempChildScopes
}
const activeScopes = computed(() => {
return getAvailableScopes(activeScope.value)
})
const actionList = computed(() => {
const sections = formattedData.value.filter((el) => el.section).map((el) => el.section)
formattedData.value.sort((a, b) => {
if (a.section && b.section) {
if (sections.indexOf(a.section) < sections.indexOf(b.section)) return -1
if (sections.indexOf(a.section) > sections.indexOf(b.section)) return 1
return 0
}
if (a.section) return 1
if (b.section) return -1
return 0
})
return formattedData.value.filter((el) => {
if (cmdInput.value === '') {
if (el.parent === activeScope.value) {
if (!el.handler) {
return isThereAnyActionInScope(el.id)
}
return true
}
return false
} else {
if (el.parent === activeScope.value || activeScopes.value.includes(el.parent || 'root')) {
if (!el.handler) {
return isThereAnyActionInScope(el.id)
}
return true
}
return false
}
})
})
const searchedActionList = computed(() => {
if (cmdInput.value === '') return actionList.value
actionList.value.sort((a, b) => {
if (a.weight > b.weight) return -1
if (a.weight < b.weight) return 1
return 0
})
return actionList.value
.filter((el) => el.weight > 0)
.sort((a, b) => b.section?.toLowerCase().localeCompare(a.section?.toLowerCase() as string) || 0)
})
const actionListGroupedBySection = computed(() => {
const rt: { [key: string]: CmdAction[] } = {}
searchedActionList.value.forEach((el) => {
if (el.section === 'hidden') return
if (el.section) {
if (!rt[el.section]) rt[el.section] = []
rt[el.section].push(el)
} else {
if (!rt.default) rt.default = []
rt.default.push(el)
}
})
return rt
})
const keys = useMagicKeys()
const setAction = (action: string) => {
selected.value = action
nextTick(() => {
const actionIndex = searchedActionList.value.findIndex((el) => el.id === action)
if (actionIndex === -1) return
if (actionIndex === 0) {
document.querySelector('.cmdk-actions')?.scrollTo({ top: 0, behavior: 'smooth' })
} else if (actionIndex === searchedActionList.value.length - 1) {
document.querySelector('.cmdk-actions')?.scrollTo({ top: 999999, behavior: 'smooth' })
} else {
document.querySelector('.cmdk-action.selected')?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
})
}
const selectFirstAction = () => {
if (searchedActionList.value.length > 0) {
setAction(searchedActionList.value[0].id)
} else {
selected.value = undefined
}
}
const setScope = (scope: string) => {
activeScope.value = scope
emits('scope', scope)
nextTick(() => {
cmdInputEl.value?.focus()
selectFirstAction()
})
}
const show = () => {
if (!user.value) return
if (props.scope === 'disabled') return
vOpen.value = true
cmdInput.value = ''
nextTick(() => {
setScope(props.scope || 'root')
})
}
const hide = () => {
vOpen.value = false
}
const fireAction = (action: CmdAction, preview = false) => {
if (preview) {
if (action?.scopePayload) {
setScope(action.scopePayload.scope)
if (action.scopePayload.data) {
props.loadTemporaryScope?.(action.scopePayload)
}
return
}
}
if (action?.handler) {
action.handler()
hide()
} else {
setScope(action.id)
}
}
whenever(keys.ctrl_k, () => {
show()
})
whenever(keys.meta_k, () => {
show()
})
whenever(keys.Escape, () => {
if (vOpen.value) hide()
})
whenever(keys.ctrl_l, () => {
if (vOpen.value) hide()
})
whenever(keys.meta_l, () => {
if (vOpen.value) hide()
})
whenever(keys.ctrl_j, () => {
if (vOpen.value) hide()
})
whenever(keys.meta_j, () => {
if (vOpen.value) hide()
})
whenever(keys.arrowup, () => {
if (vOpen.value) {
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value)
if (idx > 0) {
setAction(searchedActionList.value[idx - 1].id)
} else if (idx === 0) {
setAction(searchedActionList.value[searchedActionList.value.length - 1].id)
}
}
})
whenever(keys.arrowdown, () => {
if (vOpen.value) {
const idx = searchedActionList.value.findIndex((el) => el.id === selected.value)
if (idx < searchedActionList.value.length - 1) {
setAction(searchedActionList.value[idx + 1].id)
} else if (idx === searchedActionList.value.length - 1) {
setAction(searchedActionList.value[0].id)
}
}
})
whenever(keys.Enter, () => {
if (vOpen.value) {
const selectedEl = formattedData.value.find((el) => el.id === selected.value)
cmdInput.value = ''
if (selectedEl) {
fireAction(selectedEl, keys.shift.value)
}
}
})
whenever(keys.Backspace, () => {
if (vOpen.value && cmdInput.value === '' && activeScope.value !== 'root') {
const activeEl = formattedData.value.find((el) => el.id === activeScope.value)
setScope(activeEl?.parent || 'root')
}
})
onClickOutside(modalEl, () => {
if (vOpen.value) hide()
})
defineExpose({
open: show,
close: hide,
setScope,
})
</script>
<template>
<div />
<div v-show="vOpen" class="cmdk-modal" :class="{ 'cmdk-modal-active': vOpen }">
<div ref="modalEl" class="cmdk-modal-content h-[25.25rem]">
<div class="cmdk-header">
<div class="cmdk-input-wrapper">
<GeneralIcon icon="search" class="h-4 w-4 text-gray-500" />
<div
v-for="el of nestedScope"
:key="`cmdk-breadcrumb-${el.id}`"
v-e="['a:cmdk:setScope']"
class="text-gray-600 text-sm cursor-pointer flex gap-1 items-center font-medium capitalize"
@click="setScope(el.id)"
>
<component
:is="(iconMap as any)[el.icon]"
v-if="el.icon && typeof el.icon === 'string' && (iconMap as any)[el.icon]"
class="cmdk-action-icon"
:class="{
'!text-blue-500': el.icon === 'grid',
'!text-purple-500': el.icon === 'form',
'!text-[#FF9052]': el.icon === 'kanban',
'!text-pink-500': el.icon === 'gallery',
}"
/>
<div v-else-if="el.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="el.icon" readonly />
</div>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ el.label }}
</template>
<span class="truncate capitalize mr-4">
{{ el.label }}
</span>
</a-tooltip>
<span class="text-gray-400 text-sm font-medium pl-1">/</span>
</div>
<input
ref="cmdInputEl"
v-model="cmdInput"
class="cmdk-input"
type="text"
:placeholder="props.placeholder"
@input="selectFirstAction"
/>
</div>
</div>
<div class="cmdk-body">
<div class="cmdk-actions nc-scrollbar-md">
<div v-if="searchedActionList.length === 0">
<div class="cmdk-action">
<div class="cmdk-action-content">No action found.</div>
</div>
</div>
<template v-else>
<div
v-for="[title, section] of Object.entries(actionListGroupedBySection)"
:key="`cmdk-section-${title}`"
class="cmdk-action-section border-t-1 border-gray-200"
>
<div v-if="title !== 'default'" class="cmdk-action-section-header capitalize">{{ title }}</div>
<div class="cmdk-action-section-body">
<div
v-for="act of section"
:key="act.id"
v-e="['a:cmdk:action']"
class="cmdk-action group"
:class="{ selected: selected === act.id }"
@mouseenter="setAction(act.id)"
@click="fireAction(act)"
>
<div class="cmdk-action-content w-full">
<component
:is="(iconMap as any)[act.icon]"
v-if="act.icon && typeof act.icon === 'string' && (iconMap as any)[act.icon]"
class="cmdk-action-icon"
:class="{
'!text-blue-500': act.icon === 'grid',
'!text-purple-500': act.icon === 'form',
'!text-[#FF9052]': act.icon === 'kanban',
'!text-pink-500': act.icon === 'gallery',
}"
/>
<div v-else-if="act.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker class="!text-sm !h-4 !w-4" size="small" :emoji="act.icon" readonly />
</div>
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ act.title }}
</template>
<span class="truncate capitalize mr-4 py-0.5">
{{ act.title }}
</span>
</a-tooltip>
<div
class="bg-gray-200 text-gray-600 cmdk-keyboard hidden text-xs gap-2 p-0.5 items-center justify-center rounded-md ml-auto pl-2"
>
Enter
<div
class="bg-white border-1 items-center flex justify-center border-gray-300 text-gray-700 rounded h-5 w-5 px-0.25"
>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<CmdFooter active-cmd="cmd-k" :set-active-cmd-view="setActiveCmdView" />
</div>
</div>
</template>
<style lang="scss">
/* TODO Move styles to Windi Classes */
:root {
--cmdk-secondary-background-color: rgb(230, 230, 230);
--cmdk-secondary-text-color: rgb(101, 105, 111);
--cmdk-selected-background: rgb(245, 245, 245);
--cmdk-icon-color: var(--cmdk-secondary-text-color);
--cmdk-icon-size: 1.2em;
--cmdk-modal-background: #fff;
}
.cmdk-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 1000;
color: rgb(60, 65, 73);
font-size: 16px;
.cmdk-key {
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: 1em;
font-size: 1.25em;
border-radius: 0.25em;
background: var(--cmdk-secondary-background-color);
color: var(--cmdk-secondary-text-color);
margin-right: 0.2em;
}
.cmdk-modal-content {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 1;
-webkit-box-flex: 1;
flex-grow: 1;
padding: 0;
overflow: hidden;
margin: auto;
box-shadow: rgb(0 0 0 / 50%) 0px 16px 70px;
top: 20%;
border-radius: 16px;
max-width: 640px;
background: var(--cmdk-modal-background);
}
.cmdk-input-wrapper {
@apply py-2 px-4 flex items-center gap-2;
}
.cmdk-input {
@apply text-sm;
flex-grow: 1;
flex-shrink: 0;
margin: 0px;
border: none;
appearance: none;
background: transparent;
outline: none;
box-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color) !important;
caret-color: pink;
color: rgb(60, 65, 73);
}
.cmdk-input::placeholder {
color: rgb(165, 165, 165);
opacity: 1;
}
.cmdk-input:-ms-input-placeholder {
color: rgb(165, 165, 165);
}
.cmdk-input::-ms-input-placeholder {
color: rgb(165, 165, 165);
}
.cmdk-actions {
max-height: 310px;
margin: 0px;
padding: 0.5em 0px;
list-style: none;
scroll-behavior: smooth;
overflow: auto;
position: relative;
--scrollbar-track: initial;
--scrollbar-thumb: initial;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
overflow: overlay;
scrollbar-width: auto;
scrollbar-width: thin;
--scrollbar-thumb: #e1e3e6;
--scrollbar-track: #fff;
.cmdk-action {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
outline: none;
transition: color 0s ease 0s;
width: 100%;
font-size: 0.9em;
border-left: 4px solid transparent;
.cmdk-keyboard {
display: hidden;
}
&.selected {
cursor: pointer;
background-color: #f4f4f5;
border-left: 4px solid var(--ant-primary-color);
outline: none;
.cmdk-keyboard {
display: flex;
}
}
.cmdk-action-content {
display: flex;
align-items: center;
flex-shrink: 0.01;
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.75em 1em;
width: 100%;
}
.cmdk-action-icon {
margin-right: 0.4em;
}
}
.cmdk-action-section {
display: flex;
flex-direction: column;
width: 100%;
.cmdk-action-section-header {
display: flex;
align-items: center;
padding: 8px 16px;
font-size: 14px;
color: #6a7184;
}
}
}
.cmdk-footer {
display: flex;
border-top: 1px solid rgb(230, 230, 230);
background: rgba(242, 242, 242, 0.4);
font-size: 0.8em;
padding: 0.3em 0.6em;
color: var(--cmdk-secondary-text-color);
.cmdk-footer-left {
display: flex;
flex-grow: 1;
align-items: center;
.cmdk-key-helper {
display: flex;
align-items: center;
margin-right: 1.5em;
}
}
.cmdk-footer-right {
display: flex;
flex-grow: 1;
justify-content: flex-end;
align-items: center;
.nc-brand-icon {
margin-left: 0.5em;
}
}
}
}
</style>

315
packages/nc-gui/components/cmd-l/index.vue

@ -0,0 +1,315 @@
<script setup lang="ts">
import { onKeyUp, useDebounceFn, useVModel } from '@vueuse/core'
import { iconMap, onClickOutside } from '#imports'
import type { CommandPaletteType } from '~/lib'
const props = defineProps<{
open: boolean
setActiveCmdView: (cmd: CommandPaletteType) => void
}>()
const emits = defineEmits(['update:open'])
const vOpen = useVModel(props, 'open', emits)
const search = ref('')
const modalEl = ref<HTMLElement>()
const cmdInputEl = ref<HTMLInputElement>()
const { user } = useGlobal()
const viewStore = useViewsStore()
const { recentViews, activeView } = storeToRefs(viewStore)
const selected: Ref<string> = ref('')
const newView = ref<
| {
viewId: string | null
tableId: string
baseId: string
}
| undefined
>()
const filteredViews = computed(() => {
if (!recentViews.value) return []
const filtered = recentViews.value.filter((v) => {
if (search.value === '') return true
return v.viewName.toLowerCase().includes(search.value.toLowerCase())
})
if (filtered[0]) {
selected.value = filtered[0]?.tableID + filtered[0]?.viewName
}
return filtered
})
const changeView = useDebounceFn(
async ({ viewId, tableId, baseId }: { viewId: string | null; tableId: string; baseId: string }) => {
await viewStore.changeView({ viewId, tableId, baseId })
vOpen.value = false
},
200,
)
onKeyUp('Enter', async () => {
if (vOpen.value && newView.value) {
search.value = ''
await changeView({ viewId: newView.value.viewId, tableId: newView.value.tableId, baseId: newView.value.baseId })
}
})
function scrollToTarget() {
const element = document.querySelector('.cmdk-action.selected')
element?.scrollIntoView()
}
const moveUp = () => {
if (!filteredViews.value.length) return
const index = filteredViews.value.findIndex((v) => v.tableID + v.viewName === selected.value)
if (index === 0) {
selected.value =
filteredViews.value[filteredViews.value.length - 1].tableID + filteredViews.value[filteredViews.value.length - 1].viewName
const cmdOption = filteredViews.value[filteredViews.value.length - 1]
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
document.querySelector('.actions')?.scrollTo({ top: 99999, behavior: 'smooth' })
} else {
selected.value = filteredViews.value[index - 1].tableID + filteredViews.value[index - 1].viewName
const cmdOption = filteredViews.value[index - 1]
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
nextTick(() => scrollToTarget())
}
}
const moveDown = () => {
if (!filteredViews.value.length) return
const index = filteredViews.value.findIndex((v) => v.tableID + v.viewName === selected.value)
if (index === filteredViews.value.length - 1) {
selected.value = filteredViews.value[0].tableID + filteredViews.value[0].viewName
const cmdOption = filteredViews.value[0]
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
document.querySelector('.actions')?.scrollTo({ top: 0, behavior: 'smooth' })
} else {
selected.value = filteredViews.value[index + 1].tableID + filteredViews.value[index + 1].viewName
const cmdOption = filteredViews.value[index + 1]
newView.value = {
viewId: cmdOption.viewId ?? null,
tableId: cmdOption.tableID,
baseId: cmdOption.baseId,
}
nextTick(() => scrollToTarget())
}
}
const hide = () => {
vOpen.value = false
search.value = ''
}
onClickOutside(modalEl, () => {
hide()
})
useEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
hide()
} else if (e.key === 'Enter') {
if (newView.value) {
changeView({ viewId: newView.value.viewId, tableId: newView.value.tableId, baseId: newView.value.baseId })
}
} else if (e.key === 'ArrowUp') {
if (!vOpen.value) return
e.preventDefault()
moveUp()
} else if (e.key === 'ArrowDown') {
if (!vOpen.value) return
e.preventDefault()
moveDown()
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
if (!user.value) return
if (!vOpen.value) {
vOpen.value = true
} else {
moveUp()
}
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'l') {
if (!user.value) return
if (!vOpen.value) {
vOpen.value = true
} else moveDown()
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
hide()
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'j') {
hide()
} else if (vOpen.value) {
cmdInputEl.value?.focus()
}
})
onMounted(() => {
document.querySelector('.cmdOpt-list')?.focus()
if (!activeView.value || !filteredViews.value.length) return
const index = filteredViews.value.findIndex(
(v) => v.viewName === activeView.value?.title && v.tableID === activeView.value?.fk_model_id,
)
if (index + 1 > filteredViews.value.length) {
selected.value = filteredViews.value[0].tableID + filteredViews.value[0].viewName
} else {
if (!filteredViews.value[index + 1]) return
selected.value = filteredViews.value[index + 1].tableID + filteredViews.value[index + 1].viewName
}
})
</script>
<template>
<div v-if="vOpen" class="cmdk-modal cmdl-modal" :class="{ 'cmdk-modal-active cmdl-modal-active': vOpen }">
<div ref="modalEl" class="cmdk-modal-content cmdl-modal-content relative h-[25.25rem]">
<div class="cmdk-input-wrapper">
<GeneralIcon class="h-4 w-4 text-gray-500" icon="search" />
<input ref="cmdInputEl" v-model="search" class="cmdk-input" placeholder="Search" type="text" />
</div>
<div class="flex items-center bg-white w-full z-[50]">
<div class="text-sm px-4 py-2 text-gray-500">Recent Views</div>
</div>
<div class="flex flex-col shrink grow overflow-hidden shadow-[rgb(0_0_0_/_50%)_0px_16px_70px] max-w-[650px] p-0">
<div class="scroll-smooth actions overflow-auto nc-scrollbar-md mb-10 relative mx-0 px-0 py-2">
<div v-if="filteredViews.length < 1" class="flex flex-col p-4 items-start justify-center text-md">No recent views</div>
<div v-else class="flex flex-col cmdOpt-list w-full">
<div
v-for="cmdOption of filteredViews"
:key="cmdOption.tableID + cmdOption.viewName"
v-e="['a:cmdL:changeView']"
:class="{
selected: selected === cmdOption.tableID + cmdOption.viewName,
}"
class="cmdk-action"
@click="changeView({ viewId: cmdOption.viewId!, tableId: cmdOption.tableID, baseId: cmdOption.baseId })"
>
<div class="cmdk-action-content">
<div class="flex w-1/2 items-center">
<div class="flex gap-2">
<GeneralViewIcon :meta="{ type: cmdOption.viewType }" class="mt-0.5" />
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ cmdOption.viewName }}
</template>
<span class="truncate max-w-56 capitalize">
{{ cmdOption.viewName }}
</span>
</a-tooltip>
</div>
</div>
<div class="flex w-1/2 justify-end text-gray-600">
<div class="flex gap-2 px-2 py-1 rounded-md items-center">
<component :is="iconMap.projectGray" class="w-3 h-3 text-transparent" />
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ cmdOption.baseName }}
</template>
<span class="max-w-32 text-xs truncate capitalize">
{{ cmdOption.baseName }}
</span>
</a-tooltip>
<span class="text-bold"> / </span>
<component :is="iconMap.table" class="w-3 h-3 text-transparent" />
<a-tooltip overlay-class-name="!px-2 !py-1 !rounded-lg">
<template #title>
{{ cmdOption.tableName }}
</template>
<span class="max-w-28 text-xs truncate capitalize">
{{ cmdOption.tableName }}
</span>
</a-tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<CmdFooter active-cmd="cmd-l" :set-active-cmd-view="setActiveCmdView" />
</div>
</div>
</template>
<style lang="scss">
/* TODO Move styles to Windi Classes */
:root {
--cmdk-secondary-background-color: rgb(230, 230, 230);
--cmdk-secondary-text-color: rgb(101, 105, 111);
--cmdk-selected-background: rgb(245, 245, 245);
--cmdk-icon-color: var(--cmdk-secondary-text-color);
--cmdk-icon-size: 1.2em;
--cmdk-modal-background: #fff;
}
.cmdk-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 1000;
color: rgb(60, 65, 73);
font-size: 16px;
.cmdk-action {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
outline: none;
transition: color 0s ease 0s;
width: 100%;
font-size: 0.9em;
border-left: 4px solid transparent;
&.selected {
cursor: pointer;
background-color: rgb(248, 249, 251);
border-left: 4px solid #36f;
outline: none;
}
.cmdk-action-content {
display: flex;
align-items: center;
flex-shrink: 0.01;
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.75em 1em;
width: 640px;
}
.cmdk-action-icon {
margin-right: 0.4em;
}
}
}
</style>

3
packages/nc-gui/components/dashboard/Sidebar.vue

@ -56,7 +56,8 @@ onUnmounted(() => {
>
<DashboardTreeView v-if="!isWorkspaceLoading" />
</div>
<div v-if="!isSharedBase">
<div v-if="!isSharedBase" class="border-t-1">
<DashboardSidebarBeforeUserInfo />
<DashboardSidebarUserInfo />
</div>
</div>

3
packages/nc-gui/components/dashboard/Sidebar/BeforeUserInfo.vue

@ -0,0 +1,3 @@
<template>
<span></span>
</template>

27
packages/nc-gui/components/dashboard/Sidebar/TopSection/Header.vue

@ -1 +1,26 @@
<template><span /></template>
<script setup lang="ts">
const { commandPalette } = useCommandPalette()
</script>
<template>
<NcButton
v-e="['c:quick-actions']"
type="text"
size="small"
class="nc-sidebar-top-button w-full !hover:bg-gray-200 !rounded-md !xs:hidden"
data-testid="nc-sidebar-search-btn"
:centered="false"
@click="commandPalette?.open()"
>
<div class="flex items-center gap-2">
<MaterialSymbolsSearch class="!h-3.9" />
Quick Actions
<div
class="inline-flex gap-1 justify-center text-xs px-[8px] py-[1px] uppercase border-1 border-gray-300 rounded-md bg-slate-150 text-gray-500"
>
<kbd class="text-[16px] mt-[0.5px]"></kbd>
<kbd class="!leading-4">K</kbd>
</div>
</div>
</NcButton>
</template>

10
packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue

@ -78,7 +78,7 @@ onMounted(() => {
</script>
<template>
<div class="flex w-full flex-col p-1 border-t-1 border-gray-200 gap-y-1">
<div class="flex w-full flex-col p-1 border-gray-200 gap-y-1">
<NcDropdown v-model:visible="isMenuOpen" placement="topLeft" overlay-class-name="!min-w-64">
<div
class="flex flex-row py-2 px-3 gap-x-2 items-center hover:bg-gray-200 rounded-lg cursor-pointer h-10"
@ -134,7 +134,13 @@ onMounted(() => {
<span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
</NcMenuItem>
</a>
<a v-e="['c:nocodb:twitter']" href="https://twitter.com/nocodb" target="_blank" class="!underline-transparent">
<a
v-e="['c:nocodb:twitter']"
href="https://twitter.com/nocodb"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper group">
<GeneralIcon class="text-gray-500 group-hover:text-gray-800 my-0.5" icon="twitter" />
<span class="menu-btn"> {{ $t('labels.twitter') }} </span>

35
packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue

@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
@ -36,11 +36,16 @@ async function onOpenModal({
type,
copyViewId,
groupingFieldColumnId,
calendarRange,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
calendarRange?: Array<{
fk_from_column_id: string
fk_to_column_id: string | null // for ee only
}>
}) {
if (isViewListLoading.value) return
@ -62,6 +67,7 @@ async function onOpenModal({
type,
'tableId': table.value.id,
'selectedViewId': copyViewId,
calendarRange,
groupingFieldColumnId,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
@ -97,13 +103,21 @@ async function onOpenModal({
close(1000)
}
}
const isEasterEggEnabled = ref(false)
watch(isOpen, (val) => {
if (!val) {
isEasterEggEnabled.value = false
}
})
</script>
<template>
<NcDropdown v-model:visible="isOpen" destroy-popup-on-hide :overlay-class-name="overlayClassName" @click.stop="isOpen = true">
<NcDropdown v-model:visible="isOpen" :overlay-class-name="overlayClassName" destroy-popup-on-hide @click.stop="isOpen = true">
<slot />
<template #overlay>
<NcMenu class="max-w-48">
<NcMenu class="max-w-48" @dblclick.stop="isEasterEggEnabled = true">
<NcMenuItem @click.stop="onOpenModal({ type: ViewTypes.GRID })">
<div class="item" data-testid="sidebar-view-create-grid">
<div class="item-inner">
@ -149,6 +163,21 @@ async function onOpenModal({
<GeneralIcon v-else class="plus" icon="plus" />
</div>
</NcMenuItem>
<NcMenuItem
v-if="isEasterEggEnabled"
data-testid="sidebar-view-create-calendar"
@click="onOpenModal({ type: ViewTypes.CALENDAR })"
>
<div class="item">
<div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.CALENDAR }" />
<div>{{ $t('objects.viewType.calendar') }}</div>
</div>
<GeneralLoader v-if="toBeCreateType === ViewTypes.CALENDAR && isViewListLoading" />
<GeneralIcon v-else class="text-brand-400" icon="plus" />
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>

4
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -463,7 +463,7 @@ const projectDelete = () => {
<template v-if="!isSharedBase">
<NcMenuItem v-if="isUIAllowed('baseRename')" data-testid="nc-sidebar-project-rename" @click="enableEditMode">
<div v-e="['c:base:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="edit" class="group-hover:text-black" />
<GeneralIcon icon="rename" class="group-hover:text-black" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>
@ -717,7 +717,7 @@ const projectDelete = () => {
<template v-else-if="contextMenuTarget.type === 'table'">
<NcMenuItem v-if="isUIAllowed('tableRename')" @click="openRenameTableDialog(contextMenuTarget.value, true)">
<div v-e="['c:table:rename']" class="nc-base-option-item flex gap-2 items-center">
<GeneralIcon icon="edit" class="text-gray-700" />
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>

4
packages/nc-gui/components/dashboard/TreeView/TableList.vue

@ -22,6 +22,8 @@ const source = computed(() => base.value?.sources?.[sourceIndex.value])
const { isMobileMode } = useGlobal()
const { isUIAllowed } = useRoles()
const { baseTables } = storeToRefs(useTablesStore())
const tables = computed(() => baseTables.value.get(base.value.id!) ?? [])
@ -114,7 +116,7 @@ const initSortable = (el: Element) => {
}
watchEffect(() => {
if (menuRefs.value) {
if (menuRefs.value && isUIAllowed('viewCreateOrEdit')) {
if (menuRefs.value instanceof HTMLElement) {
initSortable(menuRefs.value)
} else {

2
packages/nc-gui/components/dashboard/TreeView/TableNode.vue

@ -268,7 +268,7 @@ const isTableOpened = computed(() => {
@click="openRenameTableDialog(table, base.sources[sourceIndex].id)"
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="edit" class="text-gray-700" />
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>

57
packages/nc-gui/components/dashboard/TreeView/ViewsList.vue

@ -22,7 +22,15 @@ import {
} from '#imports'
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
(
event: 'openModal',
data: {
type: ViewTypes
title?: string
copyViewId?: string
groupingFieldColumnId?: string
},
): void
(event: 'deleted'): void
}
@ -119,7 +127,10 @@ async function onSortEnd(evt: SortableEvent, undo = false) {
if (views.value.length < 2) return
const { newIndex = 0, oldIndex = 0 } = evt
let { newIndex = 0, oldIndex = 0 } = evt
newIndex = newIndex - 1
oldIndex = oldIndex - 1
if (newIndex === oldIndex) return
@ -149,7 +160,10 @@ async function onSortEnd(evt: SortableEvent, undo = false) {
})
}
const children = evt.to.children as unknown as HTMLLIElement[]
const children = Array.from(evt.to.children as unknown as HTMLLIElement[])
// remove `Create View` children from list
children.shift()
const previousEl = children[newIndex - 1]
const nextEl = children[newIndex + 1]
@ -158,7 +172,8 @@ async function onSortEnd(evt: SortableEvent, undo = false) {
if (!currentItem || !currentItem.id) return
const previousItem = (previousEl ? views.value.find((v) => v.id === previousEl.id) : {}) as ViewType
// set default order value as 0 if item not found
const previousItem = (previousEl ? views.value.find((v) => v.id === previousEl.id) ?? { order: 0 } : { order: 0 }) as ViewType
const nextItem = (nextEl ? views.value.find((v) => v.id === nextEl.id) : {}) as ViewType
let nextOrder: number
@ -195,7 +210,7 @@ const initSortable = (el: HTMLElement) => {
})
}
onMounted(() => menuRef.value && initSortable(menuRef.value.$el))
onMounted(() => menuRef.value && isUIAllowed('viewCreateOrEdit') && initSortable(menuRef.value.$el))
/** Navigate to view by changing url param */
async function changeView(view: ViewType) {
@ -278,7 +293,11 @@ function openDeleteDialog(view: ViewType) {
emits('deleted')
removeFromRecentViews({ viewId: view.id, tableId: view.fk_model_id, baseId: base.value.id })
removeFromRecentViews({
viewId: view.id,
tableId: view.fk_model_id,
baseId: base.value.id,
})
refreshCommandPalette()
if (activeView.value?.id === view.id) {
navigateToTable({
@ -331,11 +350,16 @@ function onOpenModal({
type,
copyViewId,
groupingFieldColumnId,
calendarRange,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
calendarRange?: Array<{
fk_from_column_id: string
fk_to_column_id: string | null // for ee only
}>
}) {
const isOpen = ref(true)
@ -347,6 +371,7 @@ function onOpenModal({
'selectedViewId': copyViewId,
groupingFieldColumnId,
'views': views,
calendarRange,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
@ -381,24 +406,24 @@ function onOpenModal({
<a-menu
ref="menuRef"
:class="{ dragging }"
class="nc-views-menu flex flex-col w-full !border-r-0 !bg-inherit"
:selected-keys="selected"
class="nc-views-menu flex flex-col w-full !border-r-0 !bg-inherit"
>
<DashboardTreeViewCreateViewBtn
v-if="isUIAllowed('viewCreateOrEdit')"
:align-left-level="isDefaultSource ? 1 : 2"
:class="{
'!pl-18 !xs:(pl-19.75)': isDefaultSource,
'!pl-23.5 !xs:(pl-27)': !isDefaultSource,
}"
:align-left-level="isDefaultSource ? 1 : 2"
>
<div
role="button"
class="nc-create-view-btn flex flex-row items-center cursor-pointer rounded-md w-full"
:class="{
'text-brand-500 hover:text-brand-600': activeTableId === table.id,
'text-gray-500 hover:text-brand-500': activeTableId !== table.id,
}"
class="nc-create-view-btn flex flex-row items-center cursor-pointer rounded-md w-full"
role="button"
>
<div class="flex flex-row items-center pl-1.25 !py-1.5 text-inherit">
<GeneralIcon icon="plus" />
@ -418,20 +443,20 @@ function onOpenModal({
v-for="view of views"
:id="view.id"
:key="view.id"
:view="view"
:on-validate="validate"
:table="table"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
:class="{
'bg-gray-200': isMarked === view.id,
'active': activeView?.id === view.id,
[`nc-${view.type ? viewTypeAlias[view.type] : undefined || view.type}-view-item`]: true,
}"
:data-view-id="view.id"
@change-view="changeView"
@open-modal="onOpenModal"
:on-validate="validate"
:table="table"
:view="view"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
@delete="openDeleteDialog"
@rename="onRename"
@change-view="changeView"
@open-modal="onOpenModal"
@select-icon="setIcon($event, view)"
/>
</template>

6
packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue

@ -222,7 +222,7 @@ watch(isDropdownOpen, async () => {
:emoji="props.view?.meta?.icon"
size="small"
:clearable="true"
:readonly="isMobileMode"
:readonly="isMobileMode || !isUIAllowed('viewCreateOrEdit')"
@emoji-selected="emits('selectIcon', $event)"
>
<template #default>
@ -235,7 +235,7 @@ watch(isDropdownOpen, async () => {
v-if="isEditing"
:ref="focusInput"
v-model:value="_title"
class="!bg-transparent !border-0 !ring-0 !outline-transparent !border-transparent"
class="!bg-transparent !border-0 !ring-0 !outline-transparent !border-transparent !pl-0"
:class="{
'font-medium': activeView?.id === vModel.id,
}"
@ -256,7 +256,7 @@ watch(isDropdownOpen, async () => {
</NcTooltip>
<div class="flex-1" />
<template v-if="!isEditing && !isLocked && isUIAllowed('viewCreateOrEdit')">
<template v-if="!isEditing && !isLocked">
<NcDropdown v-model:visible="isDropdownOpen" overlay-class-name="!rounded-lg">
<NcButton
v-e="['c:view:option']"

72
packages/nc-gui/components/dashboard/TreeView/index.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import type { TableType } from 'nocodb-sdk'
import ProjectWrapper from './ProjectWrapper.vue'
import {
@ -16,6 +16,7 @@ import {
useBase,
useBases,
useDialog,
useGlobal,
useNuxtApp,
useRoles,
useRouter,
@ -32,7 +33,7 @@ const route = router.currentRoute
const basesStore = useBases()
const { createProject: _createProject } = basesStore
const { createProject: _createProject, updateProject } = basesStore
const { bases, basesList, activeProjectId } = storeToRefs(basesStore)
@ -46,6 +47,8 @@ const { isSharedBase } = storeToRefs(baseStore)
const { activeTable: _activeTable } = storeToRefs(useTablesStore())
const { isMobileMode } = useGlobal()
const contextMenuTarget = reactive<{ type?: 'base' | 'source' | 'table' | 'main' | 'layout'; value?: any }>({})
const setMenuContext = (type: 'base' | 'source' | 'table' | 'main' | 'layout', value?: any) => {
@ -188,6 +191,38 @@ const scrollTableNode = () => {
activeTableDom?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
const onMove = async (_event: { moved: { newIndex: number; oldIndex: number; element: NcProject } }) => {
const {
moved: { newIndex = 0, oldIndex = 0, element },
} = _event
if (!element?.id) return
let nextOrder: number
// set new order value based on the new order of the items
if (basesList.value.length - 1 === newIndex) {
// If moving to the end, set nextOrder greater than the maximum order in the list
nextOrder = Math.max(...basesList.value.map((item) => item?.order ?? 0)) + 1
} else if (newIndex === 0) {
// If moving to the beginning, set nextOrder smaller than the minimum order in the list
nextOrder = Math.min(...basesList.value.map((item) => item?.order ?? 0)) / 2
} else {
nextOrder =
(parseFloat(String(basesList.value[newIndex - 1]?.order ?? 0)) +
parseFloat(String(basesList.value[newIndex + 1]?.order ?? 0))) /
2
}
const _nextOrder = !isNaN(Number(nextOrder)) ? nextOrder : oldIndex
await updateProject(element.id, {
order: _nextOrder,
})
$e('a:base:reorder')
}
watch(
() => _activeTable.value?.id,
() => {
@ -224,11 +259,24 @@ watch(
<div class="nc-treeview-container flex flex-col justify-between select-none">
<div v-if="!isSharedBase" class="text-gray-500 font-medium pl-3.5 mb-1">{{ $t('objects.projects') }}</div>
<div mode="inline" class="nc-treeview pb-0.5 flex-grow min-h-50 overflow-x-hidden">
<template v-if="basesList?.length">
<ProjectWrapper v-for="base of basesList" :key="base.id" :base-role="base.project_role" :base="base">
<DashboardTreeViewProjectNode />
</ProjectWrapper>
</template>
<div v-if="basesList?.length">
<Draggable
:model-value="basesList"
:disabled="isMobileMode || !isUIAllowed('baseReorder') || basesList?.length < 2"
item-key="id"
handle=".base-title-node"
ghost-class="ghost"
@change="onMove($event)"
>
<template #item="{ element: base }">
<div :key="base.id">
<ProjectWrapper :base-role="base.project_role" :base="base">
<DashboardTreeViewProjectNode />
</ProjectWrapper>
</div>
</template>
</Draggable>
</div>
<WorkspaceEmptyPlaceholder v-else-if="!isWorkspaceLoading" />
</div>
@ -236,4 +284,12 @@ watch(
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.ghost,
.ghost > * {
@apply pointer-events-none;
}
.ghost {
@apply bg-primary-selected;
}
</style>

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

Loading…
Cancel
Save