Browse Source

Merge branch 'develop' into master-copy

# Conflicts:
#	packages/nc-gui/package.json
#	packages/nocodb/package.json
#	pnpm-lock.yaml
pull/8403/head
Pranav C 7 months ago
parent
commit
a3b33e8050
  1. 55
      .github/workflows/bats-test.yml
  2. 5
      .github/workflows/sync-to-develop.yml
  3. 479
      docker-compose/setup-script/noco.sh
  4. 31
      docker-compose/setup-script/tests/configure/monitor.bats
  5. 31
      docker-compose/setup-script/tests/configure/restart.bats
  6. 33
      docker-compose/setup-script/tests/configure/scale.bats
  7. 17
      docker-compose/setup-script/tests/configure/setup.sh
  8. 31
      docker-compose/setup-script/tests/configure/start.bats
  9. 30
      docker-compose/setup-script/tests/configure/stop.bats
  10. 31
      docker-compose/setup-script/tests/configure/upgrade.bats
  11. 22
      docker-compose/setup-script/tests/expects/configure/monitor.sh
  12. 20
      docker-compose/setup-script/tests/expects/configure/restart.sh
  13. 23
      docker-compose/setup-script/tests/expects/configure/scale.sh
  14. 20
      docker-compose/setup-script/tests/expects/configure/start.sh
  15. 20
      docker-compose/setup-script/tests/expects/configure/stop.sh
  16. 20
      docker-compose/setup-script/tests/expects/configure/upgrade.sh
  17. 21
      docker-compose/setup-script/tests/expects/install/default.sh
  18. 22
      docker-compose/setup-script/tests/expects/install/ip.sh
  19. 33
      docker-compose/setup-script/tests/expects/install/redis.sh
  20. 33
      docker-compose/setup-script/tests/expects/install/scale.sh
  21. 38
      docker-compose/setup-script/tests/expects/install/ssl.sh
  22. 33
      docker-compose/setup-script/tests/expects/install/watchtower.sh
  23. 36
      docker-compose/setup-script/tests/install/default.bats
  24. 36
      docker-compose/setup-script/tests/install/ip.bats
  25. 32
      docker-compose/setup-script/tests/install/redis.bats
  26. 30
      docker-compose/setup-script/tests/install/scale.bats
  27. 12
      docker-compose/setup-script/tests/install/setup.sh
  28. 30
      docker-compose/setup-script/tests/install/ssl.bats
  29. 30
      docker-compose/setup-script/tests/install/watchtower.bats
  30. 3
      docker-compose/setup-script/tests/mocks/clear
  31. 3
      docker-compose/setup-script/tests/mocks/nproc
  32. 16
      markdown/readme/languages/japanese.md
  33. BIN
      packages/nc-gui/assets/img/placeholder/no-search-result-found.png
  34. 4
      packages/nc-gui/assets/nc-icons/arrow-up-right.svg
  35. 5
      packages/nc-gui/assets/nc-icons/control-panel.svg
  36. 5
      packages/nc-gui/assets/nc-icons/discord.svg
  37. 17
      packages/nc-gui/assets/nc-icons/help.svg
  38. 4
      packages/nc-gui/assets/nc-icons/home.svg
  39. 10
      packages/nc-gui/assets/nc-icons/office.svg
  40. 21
      packages/nc-gui/assets/nc-icons/record.svg
  41. 7
      packages/nc-gui/assets/nc-icons/reddit.svg
  42. 15
      packages/nc-gui/assets/nc-icons/settings.svg
  43. 11
      packages/nc-gui/assets/nc-icons/slash.svg
  44. 3
      packages/nc-gui/assets/nc-icons/twitter.svg
  45. 4
      packages/nc-gui/assets/nc-icons/workspace.svg
  46. 74
      packages/nc-gui/assets/style.scss
  47. 1
      packages/nc-gui/components.d.ts
  48. 32
      packages/nc-gui/components/account/HeaderWithSorter.vue
  49. 33
      packages/nc-gui/components/account/UserList.vue
  50. 79
      packages/nc-gui/components/account/UserMenu.vue
  51. 7
      packages/nc-gui/components/cell/Checkbox.vue
  52. 5
      packages/nc-gui/components/cell/ClampedText.vue
  53. 2
      packages/nc-gui/components/cell/Currency.vue
  54. 230
      packages/nc-gui/components/cell/DatePicker.vue
  55. 253
      packages/nc-gui/components/cell/DateTimePicker.vue
  56. 4
      packages/nc-gui/components/cell/Decimal.vue
  57. 4
      packages/nc-gui/components/cell/Email.vue
  58. 4
      packages/nc-gui/components/cell/Float.vue
  59. 4
      packages/nc-gui/components/cell/Integer.vue
  60. 43
      packages/nc-gui/components/cell/MultiSelect.vue
  61. 6
      packages/nc-gui/components/cell/Percent.vue
  62. 4
      packages/nc-gui/components/cell/PhoneNumber.vue
  63. 12
      packages/nc-gui/components/cell/Rating.vue
  64. 76
      packages/nc-gui/components/cell/RichText.vue
  65. 13
      packages/nc-gui/components/cell/RichText/LinkOptions.vue
  66. 26
      packages/nc-gui/components/cell/SingleSelect.vue
  67. 46
      packages/nc-gui/components/cell/TextArea.vue
  68. 148
      packages/nc-gui/components/cell/TimePicker.vue
  69. 6
      packages/nc-gui/components/cell/Url.vue
  70. 55
      packages/nc-gui/components/cell/User.vue
  71. 155
      packages/nc-gui/components/cell/YearPicker.vue
  72. 38
      packages/nc-gui/components/cell/attachment/index.vue
  73. 104
      packages/nc-gui/components/cmd-k/index.vue
  74. 3
      packages/nc-gui/components/dashboard/Sidebar/EEMenuOption.vue
  75. 2
      packages/nc-gui/components/dashboard/Sidebar/Header.vue
  76. 74
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  77. 4
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  78. 462
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  79. 4
      packages/nc-gui/components/dashboard/TreeView/TableList.vue
  80. 242
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  81. 4
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  82. 16
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  83. 12
      packages/nc-gui/components/dashboard/View.vue
  84. 4
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  85. 3
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  86. 42
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  87. 208
      packages/nc-gui/components/dlg/InviteDlg.vue
  88. 2
      packages/nc-gui/components/dlg/QuickImport.vue
  89. 2
      packages/nc-gui/components/dlg/TableCreate.vue
  90. 5
      packages/nc-gui/components/dlg/TableDelete.vue
  91. 17
      packages/nc-gui/components/dlg/TableRename.vue
  92. 4
      packages/nc-gui/components/dlg/ViewCreate.vue
  93. 2
      packages/nc-gui/components/erd/HistogramPanel.vue
  94. 110
      packages/nc-gui/components/extensions/Details.vue
  95. 231
      packages/nc-gui/components/extensions/Extension.vue
  96. 72
      packages/nc-gui/components/extensions/Market.vue
  97. 59
      packages/nc-gui/components/extensions/Pane.vue
  98. 18
      packages/nc-gui/components/extensions/Wrapper.vue
  99. 3
      packages/nc-gui/components/general/BaseIconColorPicker.vue
  100. 26
      packages/nc-gui/components/general/CopyButton.vue
  101. Some files were not shown because too many files have changed in this diff Show More

55
.github/workflows/bats-test.yml

@ -0,0 +1,55 @@
name: Run BATS Tests
on:
push:
paths:
- 'docker-compose/setup-script/noco.sh'
workflow_dispatch:
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install jq
run: |
sudo apt-get update
sudo apt-get install -y jq
- name: Prepare matrix for test files
id: set-matrix
run: |
BATS_FILES=$(find docker-compose/setup-script/tests -name '*.bats')
MATRIX_JSON=$(echo $BATS_FILES | jq -Rsc 'split("\n") | map(select(. != ""))')
echo "matrix=$MATRIX_JSON" >> $GITHUB_ENV
test:
needs: prepare
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
test: ${{fromJson(env.matrix)}}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install BATS
run: |
sudo apt-get update
sudo apt-get install -y bats expect
- name: Get working directory
run: |
WORKING_DIR="$(pwd)/docker-compose/setup-script/tests"
echo "WORKING_DIR=$WORKING_DIR" >> $GITHUB_ENV
- name: Run BATS test
run: bats ${{ matrix.test }}
env:
WORKING_DIR: ${{ env.WORKING_DIR }}
SKIP_TARE_DOWN: true

5
.github/workflows/sync-to-develop.yml

@ -13,6 +13,10 @@ jobs:
uses: actions/setup-node@v3
with:
node-version: 18.19.1
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Checkout
uses: actions/checkout@v3
with:
@ -39,6 +43,7 @@ jobs:
git config user.name 'github-actions[bot]'
git config user.email 'github-actions[bot]@users.noreply.github.com'
revertSDK=true node scripts/upgradeNocodbSdk.js
pnpm bootstrap
git add .
git diff-index --quiet HEAD || git commit -m "chore: update sdk path"
git push origin $BRANCH_NAME

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

@ -1,6 +1,24 @@
#!/bin/bash
# set -x
# ******************************************************************************
# ***************** GLOBAL VARIABLES START *********************************
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
MAGENTA='\033[0;35m'
CYAN='\033[0;36m'
ORANGE='\033[0;33m'
BOLD='\033[1m'
NC='\033[0m'
NOCO_HOME="${HOME}/.nocodb"
# ***************** GLOBAL VARIABLES END ***********************************
# ******************************************************************************
# ******************************************************************************
# ***************** HELPER FUNCTIONS START *********************************
@ -53,46 +71,323 @@ install_package() {
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
# Function to check if sudo is required for Docker command
check_for_docker_sudo() {
if docker ps >/dev/null 2>&1; then
echo "n"
else
echo "y"
fi
}
# Function to read a number from the user
read_number() {
local number
read -rp "$1" number
# Ensure the input is a number or empty
while ! [[ $number =~ ^[0-9]+$ ]] && [ -n "$number" ] ; do
read -rp "Please enter a valid number: " number
done
echo "$number"
}
# Function to read a number within a range from the user
read_number_range() {
local number
local min
local max
# Check if there are 3 arguments
if [ "$#" -ne 3 ]; then
number=$(read_number)
min=$1
max=$2
else
number=$(read_number "$1")
min=$2
max=$3
fi
# Ensure the input is in the specified range
while [[ -n "$number" && ($number -lt $min || $number -gt $max) ]]; do
number=$(read_number "Please enter a number between $min and $max: ")
done
echo "$number"
}
check_if_docker_is_running() {
if ! $DOCKER_COMMAND ps >/dev/null 2>&1; then
echo "+-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-+"
echo -e "| ${BOLD}${YELLOW}Warning ! ${NC} |"
echo "| Docker is not running. Most of the commands will not work without Docker. |"
echo "| Use the following command to start Docker: |"
echo -e "| ${BLUE} sudo systemctl start docker ${NC} |"
echo "+-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-+"
fi
}
# ***************** HELPER FUNCTIONS END ***********************************
# ******************************************************************************
# *****************************************************************************
# *************************** Management *************************************
# Function to display the menu
show_menu() {
clear
check_if_docker_is_running
echo ""
echo "$MSG"
echo -e "\t\t${BOLD}Service Management Menu${NC}"
echo -e " ${GREEN}1. Start Service"
echo -e " ${ORANGE}2. Stop Service"
echo -e " ${CYAN}3. Logs"
echo -e " ${MAGENTA}4. Restart"
echo -e " ${BLUE}5. Upgrade"
echo -e " 6. Scale"
echo -e " 7. Monitoring"
echo -e " ${RED}0. Exit${NC}"
}
# Function to start the service
start_service() {
echo -e "\nStarting nocodb..."
$DOCKER_COMMAND compose up -d
}
# Function to stop the service
stop_service() {
echo -e "\nStopping nocodb..."
$DOCKER_COMMAND compose stop
}
show_logs_sub_menu() {
clear
echo "Select a replica for $1:"
for i in $(seq 1 $2); do
echo "$i. \"$1\" replica $i"
done
echo "A. All"
echo "0. Back to Logs Menu"
echo "Enter replica number: "
read -r replica_choice
if [[ "$replica_choice" =~ ^[0-9]+$ ]] && [ "$replica_choice" -gt 0 ] && [ "$replica_choice" -le "$2" ]; then
container_id=$($DOCKER_COMMAND compose ps | grep "$1-$replica_choice" | cut -d " " -f 1)
$DOCKER_COMMAND logs -f "$container_id"
elif [ "$replica_choice" == "A" ] || [ "$replica_choice" == "a" ]; then
$DOCKER_COMMAND compose logs -f "$1"
elif [ "$replica_choice" == "0" ]; then
show_logs
else
show_logs_sub_menu "$1" "$2"
fi
}
# Function to show logs
show_logs() {
clear
echo "Select a container for logs:"
# Fetch the list of services
services=()
while IFS= read -r service; do
services+=("$service")
done < <($DOCKER_COMMAND compose ps --services)
service_replicas=()
count=0
# For each service, count the number of running instances
for service in "${services[@]}"; do
# Count the number of lines that have the service name, which corresponds to the number of replicas
replicas=$($DOCKER_COMMAND compose ps "$service" | grep -c "$service")
service_replicas["$count"]=$replicas
count=$((count + 1))
done
count=1
for service in "${services[@]}"; do
echo "$count. $service (${service_replicas[(($count - 1))]} replicas)"
count=$((count + 1))
done
echo "A. All"
echo "0. Back to main menu"
echo "Enter your choice: "
read -r log_choice
echo
if [[ "$log_choice" =~ ^[0-9]+$ ]] && [ "$log_choice" -gt 0 ] && [ "$log_choice" -lt "$count" ]; then
service_index=$((log_choice-1))
service="${services[$service_index]}"
num_replicas="${service_replicas[$service_index]}"
if [ "$num_replicas" -gt 1 ]; then
trap 'show_logs_sub_menu "$service" "$num_replicas"' INT
show_logs_sub_menu "$service" "$num_replicas"
trap - INT
else
trap 'show_logs' INT
$DOCKER_COMMAND compose logs -f "$service"
fi
elif [ "$log_choice" == "A" ] || [ "$log_choice" == "a" ]; then
trap 'show_logs' INT
$DOCKER_COMMAND compose logs -f
elif [ "$log_choice" == "0" ]; then
return
else
show_logs
fi
trap - INT
}
# Function to restart the service
restart_service() {
echo -e "\nRestarting nocodb..."
$DOCKER_COMMAND compose restart
}
# Function to upgrade the service
upgrade_service() {
echo -e "\nUpgrading nocodb..."
$DOCKER_COMMAND compose pull
$DOCKER_COMMAND compose up -d --force-recreate
$DOCKER_COMMAND image prune -a -f
}
# Function to scale the service
scale_service() {
num_cores=$(nproc || sysctl -n hw.ncpu || echo 1)
current_scale=$($DOCKER_COMMAND compose ps -q nocodb | wc -l)
echo -e "\nCurrent number of instances: $current_scale"
echo "How many instances of NocoDB do you want to run (Maximum: ${num_cores}) ? (default: 1): "
scale_num=$(read_number_range 1 "$num_cores")
if [ "$scale_num" -eq "$current_scale" ]; then
echo "Number of instances is already set to $scale_num. Returning to main menu."
return
fi
$DOCKER_COMMAND compose up -d --scale nocodb="$scale_num"
}
# Function for basic monitoring
monitoring_service() {
echo -e '\nLoading stats...'
trap ' ' INT
$DOCKER_COMMAND stats
}
management_menu() {
# Main program loop
while true; do
trap - INT
show_menu
echo "Enter your choice: "
read -r choice
case $choice in
1) start_service && MSG="NocoDB Started" ;;
2) stop_service && MSG="NocoDB Stopped" ;;
3) show_logs ;;
4) restart_service && MSG="NocoDB Restarted" ;;
5) upgrade_service && MSG="NocoDB has been upgraded to latest version" ;;
6) scale_service && MSG="NocoDB has been scaled" ;;
7) monitoring_service ;;
0) exit 0 ;;
*) MSG="\nInvalid choice. Please select a correct option." ;;
esac
done
}
# ******************************************************************************
# *************************** Management END **********************************
# ******************************************************************************
# ***************** Existing Install Test ************************************
IS_DOCKER_REQUIRE_SUDO=$(check_for_docker_sudo)
DOCKER_COMMAND=$([ "$IS_DOCKER_REQUIRE_SUDO" = "y" ] && echo "sudo docker" || echo "docker")
NOCO_FOUND=false
# Check if $NOCO_HOME exists as directory
if [ -d "$NOCO_HOME" ]; then
NOCO_FOUND=true
elif $DOCKER_COMMAND ps --format '{{.Names}}' | grep -q "nocodb"; then
NOCO_ID=$(docker ps | grep "nocodb/nocodb" | cut -d ' ' -f 1)
CUSTOM_HOME=$(docker inspect --format='{{index .Mounts 0}}' "$NOCO_ID" | cut -d ' ' -f 3)
PARENT_DIR=$(dirname "$CUSTOM_HOME")
ln -s "$PARENT_DIR" "$NOCO_HOME"
basename "$PARENT_DIR" > "$NOCO_HOME/.COMPOSE_PROJECT_NAME"
NOCO_FOUND=true
else
mkdir -p "$NOCO_HOME"
fi
cd "$NOCO_HOME" || exit 1
# Check if nocodb is already installed
if [ "$NOCO_FOUND" = true ]; then
echo "NocoDB is already installed. And running."
echo "Do you want to reinstall NocoDB? [Y/N] (default: N): "
read -r REINSTALL
if [ -f "$NOCO_HOME/.COMPOSE_PROJECT_NAME" ]; then
COMPOSE_PROJECT_NAME=$(cat "$NOCO_HOME/.COMPOSE_PROJECT_NAME")
export COMPOSE_PROJECT_NAME
fi
if [ "$REINSTALL" != "Y" ] && [ "$REINSTALL" != "y" ]; then
management_menu
exit 0
else
echo "Reinstalling NocoDB..."
$DOCKER_COMMAND compose down
unset COMPOSE_PROJECT_NAME
cd /tmp || exit 1
rm -rf "$NOCO_HOME"
mkdir -p "$NOCO_HOME"
cd "$NOCO_HOME" || exit 1
fi
fi
# ******************************************************************************
# ******************** SYSTEM REQUIREMENTS CHECK START *************************
# Check if the following requirements are met:
# a. docker, docker-compose, jq installed
# a. docker, 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
# 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
echo " | Checking if required tools (docker, lsof) are installed..."
for tool in docker 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
if [ "$tool" = "docker" ]; then
wget -qO- https://get.docker.com/ | sh
elif [ "$tool" = "lsof" ]; then
install_package lsof
@ -100,16 +395,11 @@ for tool in docker docker-compose lsof openssl; do
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
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."
@ -130,26 +420,7 @@ 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
message_arr+=("Setup folder: $NOCO_HOME")
# ******************** SYSTEM REQUIREMENTS CHECK END **************************
# ******************************************************************************
@ -159,34 +430,36 @@ cd "$FOLDER_NAME" || exit
# ******************** INPUTS FROM USER START ********************************
# ******************************************************************************
echo "Choose Community or Enterprise Edition [CE/EE] (default: CE): "
read EDITION
echo "Enter the IP address or domain name for the NocoDB instance (default: $PUBLIC_IP): "
read -r DOMAIN_NAME
echo "Do you want to configure SSL [Y/N] (default: N): "
read SSL_ENABLED
echo "Show Advanced Options [Y/N] (default: N): "
read -r ADVANCED_OPTIONS
if [ "$ADVANCED_OPTIONS" == "Y" ]; then
ADVANCED_OPTIONS="y"
fi
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")
if [ -n "$DOMAIN_NAME" ]; then
if [ "$ADVANCED_OPTIONS" == "y" ]; then
echo "Do you want to configure SSL [Y/N] (default: N): "
read -r SSL_ENABLED
message_arr+=("SSL: ${SSL_ENABLED}")
fi
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
DOMAIN_NAME="$PUBLIC_IP"
fi
message_arr+=("Domain: $PUBLIC_IP")
if [ "$ADVANCED_OPTIONS" == "y" ]; then
echo "Choose Community or Enterprise Edition [CE/EE] (default: CE): "
read -r EDITION
fi
if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; then
echo "Enter the NocoDB license key: "
read LICENSE_KEY
read -r LICENSE_KEY
if [ -z "$LICENSE_KEY" ]; then
echo "License key is required for Enterprise Edition installation"
exit 1
@ -194,8 +467,10 @@ if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; the
fi
echo "Do you want to enabled Redis for caching [Y/N] (default: Y): "
read REDIS_ENABLED
if [ "$ADVANCED_OPTIONS" == "y" ]; then
echo "Do you want to enabled Redis for caching [Y/N] (default: Y): "
read -r REDIS_ENABLED
fi
if [ -z "$REDIS_ENABLED" ] || { [ "$REDIS_ENABLED" != "N" ] && [ "$REDIS_ENABLED" != "n" ]; }; then
message_arr+=("Redis: Enabled")
@ -204,8 +479,10 @@ else
fi
echo "Do you want to enabled Watchtower for automatic updates [Y/N] (default: Y): "
read WATCHTOWER_ENABLED
if [ "$ADVANCED_OPTIONS" == "y" ]; then
echo "Do you want to enabled Watchtower for automatic updates [Y/N] (default: Y): "
read -r WATCHTOWER_ENABLED
fi
if [ -z "$WATCHTOWER_ENABLED" ] || { [ "$WATCHTOWER_ENABLED" != "N" ] && [ "$WATCHTOWER_ENABLED" != "n" ]; }; then
message_arr+=("Watchtower: Enabled")
@ -213,7 +490,17 @@ else
message_arr+=("Watchtower: Disabled")
fi
if [ "$ADVANCED_OPTIONS" = "y" ] ; then
NUM_CORES=$(nproc || sysctl -n hw.ncpu || echo 1)
echo "How many instances of NocoDB do you want to run (Maximum: ${NUM_CORES}) ? (default: 1): "
NUM_INSTANCES=$(read_number_range 1 "$NUM_CORES")
fi
if [ -z "$NUM_INSTANCES" ]; then
NUM_INSTANCES=1
fi
message_arr+=("Number of instances: $NUM_INSTANCES")
# ******************************************************************************
# *********************** INPUTS FROM USER END ********************************
@ -253,12 +540,13 @@ 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
deploy:
mode: replicated
replicas: ${NUM_INSTANCES}
depends_on:
- db
${DEPENDS_ON}
@ -444,6 +732,12 @@ mkdir -p ./nginx-post-config
# Create nginx config with the provided domain name
cat > ./nginx-post-config/default.conf <<EOF
upstream nocodb_backend {
least_conn;
server nocodb:8080;
}
server {
listen 80;
server_name $DOMAIN_NAME;
@ -466,7 +760,7 @@ server {
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem;
location / {
proxy_pass http://nocodb:8080;
proxy_pass http://nocodb_backend;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
@ -477,37 +771,16 @@ server {
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
cat > ./update.sh <<EOF
$DOCKER_COMMAND compose pull
$DOCKER_COMMAND compose up -d --force-recreate
$DOCKER_COMMAND 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
$DOCKER_COMMAND compose pull
$DOCKER_COMMAND compose up -d
echo 'Waiting for Nginx to start...';
@ -516,11 +789,7 @@ 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
$DOCKER_COMMAND 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"
# Initial Let's Encrypt certificate request
# Update the nginx config to use the new certificates
@ -530,12 +799,7 @@ if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
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
$DOCKER_COMMAND compose exec nginx nginx -s reload
message_arr+=("NocoDB is now available at https://$DOMAIN_NAME")
@ -545,7 +809,14 @@ else
message_arr+=("NocoDB is now available at http://localhost")
fi
print_box_message "${mecdessage_arr[@]}"
print_box_message "${message_arr[@]}"
# *************************** SETUP END *************************************
# ******************************************************************************
# ****************************************************************************
echo "Do you want to start the management menu [Y/N] (default: Y): "
read -r MANAGEMENT_MENU
if [ -z "$MANAGEMENT_MENU" ] || { [ "$MANAGEMENT_MENU" != "N" ] && [ "$MANAGEMENT_MENU" != "n" ]; }; then
management_menu
fi

31
docker-compose/setup-script/tests/configure/monitor.bats

@ -0,0 +1,31 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/configure" || exit 1
./setup.sh "setup"
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Properly runs monitor script" {
../expects/configure/restart.sh
cd "${NOCO_HOME}" || exit 1
# Verify container is running
docker compose ps | grep -q 'redis'
docker compose ps | grep -q 'watchtower'
docker compose ps | grep -q 'nocodb'
}

31
docker-compose/setup-script/tests/configure/restart.bats

@ -0,0 +1,31 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/configure" || exit 1
./setup.sh "setup"
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check all containers are restarted" {
../expects/configure/restart.sh
cd "${NOCO_HOME}" || exit 1
# Verify container is running
docker compose ps | grep -q 'redis'
docker compose ps | grep -q 'watchtower'
docker compose ps | grep -q 'nocodb'
}

33
docker-compose/setup-script/tests/configure/scale.bats

@ -0,0 +1,33 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/configure" || exit 1
./setup.sh "setup"
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check NocoDB is scaled to 3 instances" {
nproc() {
echo 4
}
../expects/configure/scale.sh
cd "${NOCO_HOME}" || exit 1
result=$(docker compose ps | grep -c "nocodb/nocodb")
[ "${result}" -eq 3 ]
}

17
docker-compose/setup-script/tests/configure/setup.sh

@ -0,0 +1,17 @@
#!/bin/bash
if [ -z "$NOCO_HOME" ]; then
NOCO_HOME="${HOME}/.nocodb"
fi
if [ -d "$NOCO_HOME" ]; then
cd "$NOCO_HOME" || exit
docker compose down
fi
cd "$WORKING_DIR" || exit
rm -rf "$NOCO_HOME"
if [ "$1" = "setup" ]; then
../noco.sh <<< $'\n\nN\n'
fi

31
docker-compose/setup-script/tests/configure/start.bats

@ -0,0 +1,31 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/configure" || exit 1
./setup.sh "setup"
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check all containers are up" {
../expects/configure/start.sh
cd "${NOCO_HOME}" || exit 1
# Verify container is running
docker compose ps | grep -q 'redis'
docker compose ps | grep -q 'watchtower'
docker compose ps | grep -q 'nocodb'
}

30
docker-compose/setup-script/tests/configure/stop.bats

@ -0,0 +1,30 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/configure" || exit 1
./setup.sh setup
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check all containers are down" {
../expects/configure/stop.sh
cd "${NOCO_HOME}" || exit 1
# Verify container is not running
count=$(docker compose ps -q | wc -l)
[ "$count" -eq 0 ]
}

31
docker-compose/setup-script/tests/configure/upgrade.bats

@ -0,0 +1,31 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/configure" || exit 1
./setup.sh "setup"
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check all containers are upgraded" {
../expects/configure/upgrade.sh
cd "${NOCO_HOME}" || exit 1
# Verify container is running
docker compose ps | grep -q 'redis'
docker compose ps | grep -q 'watchtower'
docker compose ps | grep -q 'nocodb'
}

22
docker-compose/setup-script/tests/expects/configure/monitor.sh

@ -0,0 +1,22 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
expect "Do you want to reinstall NocoDB*"
send "N\r"
expect "Enter your choice: "
send "7\r"
send \x03
expect "Enter your choice: "
send "0\r"
expect EOF

20
docker-compose/setup-script/tests/expects/configure/restart.sh

@ -0,0 +1,20 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
expect "Do you want to reinstall NocoDB*"
send "N\r"
expect "Enter your choice: "
send "4\r"
expect "Enter your choice: "
send "0\r"
expect EOF

23
docker-compose/setup-script/tests/expects/configure/scale.sh

@ -0,0 +1,23 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
expect "Do you want to reinstall NocoDB*"
send "N\r"
expect "Enter your choice: "
send "6\r"
expect "How many instances of NocoDB do you want to run*"
send "3\r"
expect "Enter your choice: "
send "0\r"
expect EOF

20
docker-compose/setup-script/tests/expects/configure/start.sh

@ -0,0 +1,20 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
expect "Do you want to reinstall NocoDB*"
send "N\r"
expect "Enter your choice: "
send "1\r"
expect "Enter your choice: "
send "0\r"
expect EOF

20
docker-compose/setup-script/tests/expects/configure/stop.sh

@ -0,0 +1,20 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
expect "Do you want to reinstall NocoDB*"
send "N\r"
expect "Enter your choice: "
send "2\r"
expect "Enter your choice: "
send "0\r"
expect EOF

20
docker-compose/setup-script/tests/expects/configure/upgrade.sh

@ -0,0 +1,20 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
expect "Do you want to reinstall NocoDB*"
send "N\r"
expect "Enter your choice: "
send "5\r"
expect "Enter your choice: "
send "0\r"
expect EOF

21
docker-compose/setup-script/tests/expects/install/default.sh

@ -0,0 +1,21 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
# Respond to script prompts
expect "Enter the IP address or domain name for the NocoDB instance (default: localhost):"
send "\r"
expect "Show Advanced Options*"
send "\r"
expect "Do you want to start the management menu*"
send "N\r"
expect eof

22
docker-compose/setup-script/tests/expects/install/ip.sh

@ -0,0 +1,22 @@
#!/usr/bin/expect -f
# shellcheck shell=bash
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
# Respond to script prompts
expect "Enter the IP address or domain name for the NocoDB instance (default: localhost):"
send "192.168.1.10\r"
expect "Show Advanced Options*"
send "\r"
expect "Do you want to start the management menu*"
send "N\r"
expect eof

33
docker-compose/setup-script/tests/expects/install/redis.sh

@ -0,0 +1,33 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
# Respond to script prompts
expect "Enter the IP address or domain name for the NocoDB instance (default: localhost):"
send "\r"
expect "Show Advanced Options*"
send "Y\r"
expect "Choose Community or Enterprise Edition*"
send "\r"
expect "Do you want to enabled Redis for caching*"
send "Y\r"
expect "Do you want to enabled Watchtower for automatic updates*"
send "\r"
expect "How many instances of NocoDB do you want to run*"
send "\r"
expect "Do you want to start the management menu*"
send "N\r"
expect eof

33
docker-compose/setup-script/tests/expects/install/scale.sh

@ -0,0 +1,33 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
# Respond to script prompts
expect "Enter the IP address or domain name for the NocoDB instance (default: localhost):"
send "\r"
expect "Show Advanced Options*"
send "Y\r"
expect "Choose Community or Enterprise Edition*"
send "\r"
expect "Do you want to enabled Redis for caching*"
send "Y\r"
expect "Do you want to enabled Watchtower for automatic updates*"
send "\r"
expect "How many instances of NocoDB do you want to run*"
send "2\r"
expect "Do you want to start the management menu*"
send "N\r"
expect eof

38
docker-compose/setup-script/tests/expects/install/ssl.sh

@ -0,0 +1,38 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
set random_number [lindex $argv 0]
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
# Respond to script prompts
expect "Enter the IP address or domain name for the NocoDB instance (default: localhost):"
send "${random_number}.ssl.nocodb.dev\r"
expect "Show Advanced Options*"
send "y\r"
expect "Do you want to configure SSL*"
send "y\r"
expect "Choose Community or Enterprise Edition*"
send "\r"
expect "Do you want to enabled Redis for caching*"
send "Y\r"
expect "Do you want to enabled Watchtower for automatic updates*"
send "\r"
expect "How many instances of NocoDB do you want to run*"
send "\r"
expect "Do you want to start the management menu*"
send "N\r"
expect eof

33
docker-compose/setup-script/tests/expects/install/watchtower.sh

@ -0,0 +1,33 @@
#!/usr/bin/expect -f
# Configure timeout for each expect command
set timeout 10
# Start your main script
set env(PATH) "$env(WORKING_DIR)/mocks:$env(PATH)"
spawn bash ../../noco.sh
# Respond to script prompts
expect "Enter the IP address or domain name for the NocoDB instance (default: localhost):"
send "\r"
expect "Show Advanced Options*"
send "Y\r"
expect "Choose Community or Enterprise Edition*"
send "\r"
expect "Do you want to enabled Redis for caching*"
send "\r"
expect "Do you want to enabled Watchtower for automatic updates*"
send "Y\r"
expect "How many instances of NocoDB do you want to run*"
send "\r"
expect "Do you want to start the management menu*"
send "N\r"
expect eof

36
docker-compose/setup-script/tests/install/default.bats

@ -0,0 +1,36 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check installation with all default options" {
../expects/install/default.sh
cd "${NOCO_HOME}"
# Check Docker Compose file to verify configuration
grep -q 'redis' docker-compose.yml
grep -q 'watchtower' docker-compose.yml
grep -q 'nocodb' docker-compose.yml
# Verify container is running
docker compose ps | grep -q 'redis'
docker compose ps | grep -q 'watchtower'
docker compose ps | grep -q 'nocodb'
}

36
docker-compose/setup-script/tests/install/ip.bats

@ -0,0 +1,36 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check installation with custom ip" {
../expects/install/ip.sh
cd "${NOCO_HOME}"
# Check Docker Compose file to verify configuration
grep -q 'redis' docker-compose.yml
grep -q 'watchtower' docker-compose.yml
grep -q 'nocodb' docker-compose.yml
# Verify container is running
docker compose ps | grep -q 'redis'
docker compose ps | grep -q 'watchtower'
docker compose ps | grep -q 'nocodb'
}

32
docker-compose/setup-script/tests/install/redis.bats

@ -0,0 +1,32 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check Redis is enabled when specified" {
../expects/install/redis.sh
cd "${NOCO_HOME}"
# Check Docker Compose file to verify Redis configuration
grep -q 'redis' docker-compose.yml
# Verify Redis container is running
docker compose ps | grep -q 'redis'
}

30
docker-compose/setup-script/tests/install/scale.bats

@ -0,0 +1,30 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check if two instances of NoCoDB can be run" {
../expects/install/scale.sh
cd "${NOCO_HOME}"
# Get scale from docker compose ps
scale=$(docker compose ps | grep -c "nocodb/nocodb")
[ "$scale" -eq 2 ]
}

12
docker-compose/setup-script/tests/install/setup.sh

@ -0,0 +1,12 @@
#!/bin/bash
if [ -z "$NOCO_HOME" ]; then
NOCO_HOME="${HOME}/.nocodb"
fi
if [ -d "$NOCO_HOME" ]; then
cd "$NOCO_HOME" || exit
docker compose down
fi
rm -rf "$NOCO_HOME"

30
docker-compose/setup-script/tests/install/ssl.bats

@ -0,0 +1,30 @@
#!/usr/bin/env bats
RANDOM_NUMBER=$RANDOM
setup() {
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Should create SSL certificates" {
if [ -z "$TEST_SSL" ]
then
skip "Skipping SSL tests"
fi
../expects/install/ssl.sh "$RANDOM_NUMBER"
curl -ksS --head "https://${RANDOM_NUMBER}.ssl.nocodb.dev" > /dev/null
}

30
docker-compose/setup-script/tests/install/watchtower.bats

@ -0,0 +1,30 @@
#!/usr/bin/env bats
NOCO_HOME="${HOME}/.nocodb"
export NOCO_HOME
setup() {
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
teardown() {
if [ -n "$SKIP_TEARDOWN" ]; then
return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
}
@test "Check WatchTower is enabled when specified" {
../expects/install/watchtower.sh
cd "${NOCO_HOME}"
# Check Docker Compose file to verify WatchTower configuration
grep -q 'watchtower' docker-compose.yml
# Verify WatchTower container is running
docker compose ps | grep -q 'watchtower'
}

3
docker-compose/setup-script/tests/mocks/clear

@ -0,0 +1,3 @@
#!/bin/bash
echo "--- Clear Mock ---"

3
docker-compose/setup-script/tests/mocks/nproc

@ -0,0 +1,3 @@
#!/bin/bash
echo 4

16
markdown/readme/languages/japanese.md

@ -6,7 +6,7 @@
</h1>
<p align="center">
MySQL、PostgreSQL、SQL Server、SQLite&Mariadbをスマートスプレッドシートに変ます。
MySQL、PostgreSQL、SQL Server、SQLite&Mariadbをスマートスプレッドシートに変換します。
</p>
<div align="center">
@ -41,7 +41,7 @@ docker run -d --name nocodb -p 8080:8080 nocodb/nocodb:latest
```
- NocoDBは入力としてデータベースが必要です:[本番環境設定](https://github.com/nocodb/nocodb/blob/master/README.md#production-setup)を参照してください。
- この入力がない場合、SQLiteにフォールバックする。SQLiteを持続させるために、`/usr/app/data/`をマウントします。
- この入力がない場合、SQLiteにフォールバックします。SQLiteでデータを保持するために、`/usr/app/data/`をマウントします。
例:
@ -49,7 +49,7 @@ docker run -d --name nocodb -p 8080:8080 nocodb/nocodb:latest
docker run -d -p 8080:8080 --name nocodb -v "$(pwd)"/nocodb:/usr/app/data/ nocodb/nocodb:latest
```
### NPM を使用して
### NPM を使用して初期化を行う
```
npx create-nocodb-app
@ -66,7 +66,7 @@ npm start
### GUI
アクセスダッシュボードを使用して : [http://localhost:8080/dashboard](http://localhost:8080/dashboard)
アクセスダッシュボードを使用する : [http://localhost:8080/dashboard](http://localhost:8080/dashboard)
# 私たちのコミュニティに参加する
@ -115,7 +115,7 @@ npm start
### リッチスプレッドシートインターフェース
検索、並べ替え、フィルタリング、列を隠す
検索、並べ替え、フィルタリング、列の非表示
- ⚡ ビューを作成する:グリッド、ギャラリー、カンバン、ガント、フォーム
- ⚡ シェアビュー:Public&Password Protected.
@ -194,10 +194,10 @@ docker-compose up -d
[コントリビューションガイド](https://github.com/nocodb/nocodb/blob/master/.github/CONTRIBUTING.md)をご参照ください。
# なぜこれを構築しているのですか?
# 開発の目的
ほとんどのインターネットビジネスは、ビジネスニーズを解決するためにスプレッドシートかデータベースのどちらかを装備しています。表計算ソフトは、毎日10億人以上の人が共同作業で使っています。しかし、コンピューティングに関しては、より強力なツールであるデータベースで同様のスピードで作業するのは、かなり遅れています。SaaSでこれを解決しようとすると、ひどいアクセスコントロール、ベンダーの囲い込み、データの囲い込み、突然の価格変更、そして最も重要なことは、将来的に何が可能かというガラスの天井を意味することになるのです。
ほとんどのインターネットビジネスは、ビジネスニーズを解決するためにスプレッドシートかデータベースのどちらかを用いています。表計算ソフトは、毎日10億人以上の人が共同作業で使っています。しかし、コンピューティングに関しては、より強力なツールであるデータベースで同様のスピードで作業するのは、かなり遅れています。SaaSでこれを解決しようとすると、ひどいアクセスコントロール、ベンダーの囲い込み、データの囲い込み、突然の価格変更、そして最も重要なこととしては、将来における可能性に対する隠れた制限が存在することです
# 私たちの使命
私たちの使命は、データベース用の最も強力なノーコードインターフェイスを、世界中のすべてのインターネットビジネスにオープンソースで提供することです。これは、強力なコンピューティングツールへのアクセスを民主化するだけでなく、インターネット上で根本的な改と構築の能力を持つ10億人以上の人々を生み出すでしょう。
私たちの使命は、データベース用の最も強力なノーコードインターフェイスを、世界中のすべてのインターネットビジネスにオープンソースで提供することです。これは、強力なコンピューティングツールへのアクセスを民主化するだけでなく、インターネット上で根本的な改と構築の能力を持つ10億人以上の人々を生み出すでしょう。

BIN
packages/nc-gui/assets/img/placeholder/no-search-result-found.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

4
packages/nc-gui/assets/nc-icons/arrow-up-right.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="M4.66666 11.3334L11.3333 4.66675" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66666 4.66675H11.3333V11.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

5
packages/nc-gui/assets/nc-icons/control-panel.svg

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 14V6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 597 B

5
packages/nc-gui/assets/nc-icons/discord.svg

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12.8516 3.88457C11.9593 3.47514 11.0024 3.17349 10.0019 3.00072C9.98372 2.99739 9.96552 3.00572 9.95613 3.02239C9.83307 3.24126 9.69676 3.52681 9.6013 3.75124C8.52524 3.59014 7.45469 3.59014 6.40069 3.75124C6.30521 3.52182 6.16395 3.24126 6.04033 3.02239C6.03095 3.00628 6.01275 2.99795 5.99453 3.00072C4.99461 3.17294 4.03774 3.47459 3.14488 3.88457C3.13715 3.88791 3.13052 3.89347 3.12613 3.90068C1.31115 6.61223 0.813946 9.25712 1.05786 11.8692C1.05896 11.882 1.06613 11.8942 1.07607 11.902C2.27354 12.7814 3.4335 13.3153 4.57191 13.6691C4.59013 13.6747 4.60944 13.668 4.62103 13.653C4.89032 13.2853 5.13037 12.8975 5.33619 12.4897C5.34834 12.4659 5.33675 12.4375 5.31192 12.4281C4.93116 12.2836 4.5686 12.1075 4.21984 11.9076C4.19226 11.8915 4.19005 11.852 4.21543 11.8331C4.28882 11.7781 4.36223 11.7209 4.43231 11.6631C4.44499 11.6526 4.46265 11.6503 4.47756 11.657C6.76875 12.7031 9.24923 12.7031 11.5134 11.657C11.5283 11.6498 11.546 11.652 11.5592 11.6626C11.6293 11.7203 11.7027 11.7781 11.7766 11.8331C11.802 11.852 11.8003 11.8915 11.7728 11.9076C11.424 12.1114 11.0614 12.2836 10.6801 12.4275C10.6553 12.437 10.6443 12.4659 10.6564 12.4897C10.8666 12.8969 11.1067 13.2847 11.371 13.6525C11.3821 13.668 11.4019 13.6747 11.4201 13.6691C12.5641 13.3153 13.724 12.7814 14.9215 11.902C14.932 11.8942 14.9386 11.8826 14.9397 11.8698C15.2316 8.8499 14.4508 6.2267 12.8698 3.90124C12.8659 3.89347 12.8593 3.88791 12.8516 3.88457ZM5.67835 10.2787C4.98854 10.2787 4.42016 9.64544 4.42016 8.86769C4.42016 8.08994 4.97752 7.45665 5.67835 7.45665C6.38468 7.45665 6.94755 8.0955 6.93651 8.86769C6.93651 9.64544 6.37915 10.2787 5.67835 10.2787ZM10.3303 10.2787C9.64048 10.2787 9.0721 9.64544 9.0721 8.86769C9.0721 8.08994 9.62944 7.45665 10.3303 7.45665C11.0366 7.45665 11.5995 8.0955 11.5885 8.86769C11.5885 9.64544 11.0366 10.2787 10.3303 10.2787Z"
fill="#5865F2" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

17
packages/nc-gui/assets/nc-icons/help.svg

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_3711_477)">
<path
d="M8.00004 14.6667C11.6819 14.6667 14.6667 11.6819 14.6667 8.00004C14.6667 4.31814 11.6819 1.33337 8.00004 1.33337C4.31814 1.33337 1.33337 4.31814 1.33337 8.00004C1.33337 11.6819 4.31814 14.6667 8.00004 14.6667Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 11.3334H8.00667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M6.06006 6.00001C6.21679 5.55446 6.52616 5.17875 6.93336 4.93944C7.34056 4.70012 7.81932 4.61264 8.28484 4.69249C8.75036 4.77234 9.1726 5.01436 9.47678 5.3757C9.78095 5.73703 9.94743 6.19436 9.94673 6.66668C9.94673 8.00001 7.94673 8.66668 7.94673 8.66668"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</g>
<defs>
<clipPath id="clip0_3711_477">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

4
packages/nc-gui/assets/nc-icons/home.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="M2 5.99992L8 1.33325L14 5.99992V13.3333C14 13.6869 13.8595 14.026 13.6095 14.2761C13.3594 14.5261 13.0203 14.6666 12.6667 14.6666H3.33333C2.97971 14.6666 2.64057 14.5261 2.39052 14.2761C2.14048 14.026 2 13.6869 2 13.3333V5.99992Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 14.6667V8H10V14.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

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

@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_130_18151)">
<path d="M31 31.3606H0.999999C0.800999 31.3606 0.639999 31.1996 0.639999 31.0006V1.00063C0.639999 0.801625 0.800999 0.640625 0.999999 0.640625H19C19.199 0.640625 19.36 0.801625 19.36 1.00063V3.64062H25C25.199 3.64062 25.36 3.80163 25.36 4.00063V7.64062H31C31.199 7.64062 31.36 7.80163 31.36 8.00063V31.0006C31.36 31.1996 31.199 31.3606 31 31.3606ZM19.36 30.6406H30.64V8.36062H19.36V30.6406ZM12.36 30.6406H18.64V1.36063H1.36V30.6406H7.64V23.0006C7.64 22.8016 7.801 22.6406 8 22.6406H12C12.199 22.6406 12.36 22.8016 12.36 23.0006V30.6406ZM8.36 30.6406H11.64V23.3606H8.36V30.6406ZM19.36 7.64062H24.639V4.36063H19.36V7.64062ZM27.36 25.0006H26.64V23.0006H27.361L27.36 25.0006ZM23.36 25.0006H22.64V23.0006H23.361L23.36 25.0006ZM16.36 25.0006H15.64V23.0006H16.36V25.0006ZM4.36 25.0006H3.64V23.0006H4.36V25.0006ZM27.36 19.0006H26.64V17.0006H27.361L27.36 19.0006ZM23.36 19.0006H22.64V17.0006H23.361L23.36 19.0006ZM16.36 19.0006H15.64V17.0006H16.36V19.0006ZM12.36 19.0006H11.64V17.0006H12.36V19.0006ZM8.36 19.0006H7.64V17.0006H8.36V19.0006ZM4.36 19.0006H3.64V17.0006H4.36V19.0006ZM27.36 13.0006H26.64V11.0006H27.361L27.36 13.0006ZM23.36 13.0006H22.64V11.0006H23.361L23.36 13.0006ZM16.36 13.0006H15.64V11.0006H16.36V13.0006ZM12.36 13.0006H11.64V11.0006H12.36V13.0006ZM8.36 13.0006H7.64V11.0006H8.36V13.0006ZM4.36 13.0006H3.64V11.0006H4.36V13.0006ZM16.36 7.00063H15.64V5.00063H16.36V7.00063ZM12.36 7.00063H11.64V5.00063H12.36V7.00063ZM8.36 7.00063H7.64V5.00063H8.36V7.00063ZM4.36 7.00063H3.64V5.00063H4.36V7.00063Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_130_18151">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

21
packages/nc-gui/assets/nc-icons/record.svg

@ -1,12 +1,13 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1613_80692)">
<path d="M11.8571 5.96903L4.14285 10.4225" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="1.15184" width="9.06208" height="9.06208" rx="1.335" transform="matrix(0.866044 -0.499967 0.866044 0.499967 -0.845705 8.77156)" stroke="#374151" stroke-width="1.33"/>
<path d="M3.5 6.34009L11.2143 10.7935" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_1613_80692">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
<path
d="M1.21396 7.35265C0.928679 7.19673 0.928679 6.80327 1.21396 6.64735L7.7893 3.05351C7.91986 2.98216 8.08012 2.98216 8.21067 3.05351L14.786 6.64735C15.0713 6.80327 15.0713 7.19673 14.786 7.35265L8.21067 10.9465C8.08012 11.0178 7.91986 11.0178 7.7893 10.9465L1.21396 7.35265Z"
stroke="currentColor" stroke-width="1.33" />
<path
d="M8.21067 13.9465L14.786 10.3527C14.9287 10.2747 15 10.1374 15 10V7.3702C15 7.2939 14.918 7.24571 14.8513 7.28284L8.04867 11.0729C8.01841 11.0897 7.98159 11.0897 7.95133 11.0729L1.14867 7.28284C1.08202 7.24571 1 7.2939 1 7.3702V10C1 10.1374 1.07132 10.2747 1.21396 10.3527L7.7893 13.9465C7.91986 14.0179 8.08012 14.0179 8.21067 13.9465Z"
stroke="currentColor" stroke-width="1.33" />
<path d="M4.5 5.02069L11.5 9.06179" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M4.5 9.06152L11.5 5.02042" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<line x1="7.995" y1="11" x2="7.995" y2="14" stroke="currentColor" stroke-width="1.33" />
</svg>

Before

Width:  |  Height:  |  Size: 686 B

After

Width:  |  Height:  |  Size: 1.2 KiB

7
packages/nc-gui/assets/nc-icons/reddit.svg

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15Z"
fill="#FF4500" />
<path
d="M12.6667 8.00008C12.6667 7.43517 12.2082 6.97669 11.6433 6.97669C11.3649 6.97669 11.1193 7.08312 10.9392 7.26324C10.2433 6.76382 9.27722 6.43634 8.21289 6.3954L8.67956 4.20944L10.1942 4.52874C10.2106 4.91353 10.5298 5.22464 10.9228 5.22464C11.324 5.22464 11.6515 4.89716 11.6515 4.49599C11.6515 4.09482 11.324 3.76733 10.9228 3.76733C10.6363 3.76733 10.3907 3.93108 10.276 4.17669L8.58131 3.81646C8.53219 3.80827 8.48307 3.81646 8.44213 3.84102C8.40119 3.86558 8.37663 3.90652 8.36026 3.95564L7.84447 6.3954C6.75558 6.42815 5.78131 6.74745 5.07722 7.26324C4.8971 7.09131 4.6433 6.97669 4.37312 6.97669C3.80821 6.97669 3.34973 7.43517 3.34973 8.00008C3.34973 8.41763 3.59535 8.76967 3.95558 8.93342C3.93921 9.03166 3.93102 9.1381 3.93102 9.24453C3.93102 10.8165 5.75675 12.0855 8.0164 12.0855C10.276 12.0855 12.1018 10.8165 12.1018 9.24453C12.1018 9.1381 12.0936 9.03985 12.0772 8.9416C12.4129 8.77786 12.6667 8.41763 12.6667 8.00008ZM5.66669 8.72874C5.66669 8.32757 5.99418 8.00008 6.39535 8.00008C6.79652 8.00008 7.124 8.32757 7.124 8.72874C7.124 9.12991 6.79652 9.45739 6.39535 9.45739C5.99418 9.45739 5.66669 9.12991 5.66669 8.72874ZM9.7357 10.6527C9.23628 11.1521 8.28657 11.1849 8.00821 11.1849C7.72985 11.1849 6.77195 11.1439 6.28073 10.6527C6.20704 10.579 6.20704 10.4562 6.28073 10.3825C6.35441 10.3089 6.47722 10.3089 6.5509 10.3825C6.86201 10.6936 7.53336 10.8083 8.0164 10.8083C8.49944 10.8083 9.1626 10.6936 9.4819 10.3825C9.55558 10.3089 9.67839 10.3089 9.75207 10.3825C9.80938 10.4644 9.80938 10.579 9.7357 10.6527ZM9.6047 9.45739C9.20353 9.45739 8.87605 9.12991 8.87605 8.72874C8.87605 8.32757 9.20353 8.00008 9.6047 8.00008C10.0059 8.00008 10.3334 8.32757 10.3334 8.72874C10.3334 9.12991 10.0059 9.45739 9.6047 9.45739Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

15
packages/nc-gui/assets/nc-icons/settings.svg

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clip-path="url(#clip0_3711_1275)">
<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" />
<path
d="M12.9333 9.99996C12.8445 10.201 12.8181 10.4241 12.8573 10.6404C12.8965 10.8566 12.9996 11.0562 13.1533 11.2133L13.1933 11.2533C13.3173 11.3771 13.4156 11.5242 13.4827 11.686C13.5498 11.8479 13.5843 12.0214 13.5843 12.1966C13.5843 12.3718 13.5498 12.5453 13.4827 12.7072C13.4156 12.8691 13.3173 13.0161 13.1933 13.14C13.0695 13.2639 12.9224 13.3623 12.7605 13.4294C12.5987 13.4965 12.4252 13.531 12.25 13.531C12.0747 13.531 11.9012 13.4965 11.7394 13.4294C11.5775 13.3623 11.4305 13.2639 11.3066 13.14L11.2666 13.1C11.1095 12.9463 10.9099 12.8432 10.6937 12.804C10.4774 12.7647 10.2544 12.7912 10.0533 12.88C9.85611 12.9645 9.68795 13.1048 9.5695 13.2836C9.45105 13.4625 9.38748 13.6721 9.38663 13.8866V14C9.38663 14.3536 9.24615 14.6927 8.9961 14.9428C8.74605 15.1928 8.40691 15.3333 8.05329 15.3333C7.69967 15.3333 7.36053 15.1928 7.11048 14.9428C6.86044 14.6927 6.71996 14.3536 6.71996 14V13.94C6.7148 13.7193 6.64337 13.5053 6.51497 13.3258C6.38656 13.1462 6.20712 13.0095 5.99996 12.9333C5.79888 12.8445 5.57583 12.8181 5.35957 12.8573C5.1433 12.8965 4.94375 12.9996 4.78663 13.1533L4.74663 13.1933C4.6228 13.3173 4.47574 13.4156 4.31388 13.4827C4.15202 13.5498 3.97851 13.5843 3.80329 13.5843C3.62807 13.5843 3.45457 13.5498 3.29271 13.4827C3.13084 13.4156 2.98379 13.3173 2.85996 13.1933C2.73599 13.0695 2.63765 12.9224 2.57055 12.7605C2.50345 12.5987 2.46891 12.4252 2.46891 12.25C2.46891 12.0747 2.50345 11.9012 2.57055 11.7394C2.63765 11.5775 2.73599 11.4305 2.85996 11.3066L2.89996 11.2666C3.05365 11.1095 3.15675 10.9099 3.19596 10.6937C3.23517 10.4774 3.2087 10.2544 3.11996 10.0533C3.03545 9.85611 2.89513 9.68795 2.71627 9.5695C2.53741 9.45105 2.32782 9.38748 2.11329 9.38663H1.99996C1.64634 9.38663 1.3072 9.24615 1.05715 8.9961C0.807102 8.74605 0.666626 8.40691 0.666626 8.05329C0.666626 7.69967 0.807102 7.36053 1.05715 7.11048C1.3072 6.86044 1.64634 6.71996 1.99996 6.71996H2.05996C2.28062 6.7148 2.49463 6.64337 2.67416 6.51497C2.85369 6.38656 2.99044 6.20712 3.06663 5.99996C3.15537 5.79888 3.18184 5.57583 3.14263 5.35957C3.10342 5.1433 3.00032 4.94375 2.84663 4.78663L2.80663 4.74663C2.68266 4.6228 2.58431 4.47574 2.51721 4.31388C2.45011 4.15202 2.41558 3.97851 2.41558 3.80329C2.41558 3.62807 2.45011 3.45457 2.51721 3.29271C2.58431 3.13084 2.68266 2.98379 2.80663 2.85996C2.93046 2.73599 3.07751 2.63765 3.23937 2.57055C3.40124 2.50345 3.57474 2.46891 3.74996 2.46891C3.92518 2.46891 4.09868 2.50345 4.26055 2.57055C4.42241 2.63765 4.56946 2.73599 4.69329 2.85996L4.73329 2.89996C4.89041 3.05365 5.08997 3.15675 5.30623 3.19596C5.5225 3.23517 5.74555 3.2087 5.94663 3.11996H5.99996C6.19714 3.03545 6.3653 2.89513 6.48375 2.71627C6.60221 2.53741 6.66577 2.32782 6.66663 2.11329V1.99996C6.66663 1.64634 6.8071 1.3072 7.05715 1.05715C7.3072 0.807102 7.64634 0.666626 7.99996 0.666626C8.35358 0.666626 8.69272 0.807102 8.94277 1.05715C9.19282 1.3072 9.33329 1.64634 9.33329 1.99996V2.05996C9.33415 2.27448 9.39771 2.48408 9.51616 2.66294C9.63461 2.8418 9.80278 2.98212 9.99996 3.06663C10.201 3.15537 10.4241 3.18184 10.6404 3.14263C10.8566 3.10342 11.0562 3.00032 11.2133 2.84663L11.2533 2.80663C11.3771 2.68266 11.5242 2.58431 11.686 2.51721C11.8479 2.45011 12.0214 2.41558 12.1966 2.41558C12.3718 2.41558 12.5453 2.45011 12.7072 2.51721C12.8691 2.58431 13.0161 2.68266 13.14 2.80663C13.2639 2.93046 13.3623 3.07751 13.4294 3.23937C13.4965 3.40124 13.531 3.57474 13.531 3.74996C13.531 3.92518 13.4965 4.09868 13.4294 4.26055C13.3623 4.42241 13.2639 4.56946 13.14 4.69329L13.1 4.73329C12.9463 4.89041 12.8432 5.08997 12.804 5.30623C12.7647 5.5225 12.7912 5.74555 12.88 5.94663V5.99996C12.9645 6.19714 13.1048 6.3653 13.2836 6.48375C13.4625 6.60221 13.6721 6.66577 13.8866 6.66663H14C14.3536 6.66663 14.6927 6.8071 14.9428 7.05715C15.1928 7.3072 15.3333 7.64634 15.3333 7.99996C15.3333 8.35358 15.1928 8.69272 14.9428 8.94277C14.6927 9.19282 14.3536 9.33329 14 9.33329H13.94C13.7254 9.33415 13.5158 9.39771 13.337 9.51616C13.1581 9.63461 13.0178 9.80278 12.9333 9.99996V9.99996Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</g>
<defs>
<clipPath id="clip0_3711_1275">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

11
packages/nc-gui/assets/nc-icons/slash.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 id="slash" clip-path="url(#clip0_311_1327)">
<path id="Vector" d="M7.99998 14.6668C11.6819 14.6668 14.6666 11.6821 14.6666 8.00016C14.6666 4.31826 11.6819 1.3335 7.99998 1.3335C4.31808 1.3335 1.33331 4.31826 1.33331 8.00016C1.33331 11.6821 4.31808 14.6668 7.99998 14.6668Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M3.28662 3.28662L12.7133 12.7133" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_311_1327">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 732 B

3
packages/nc-gui/assets/nc-icons/twitter.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M1.03413 1L6.43938 8.22732L1 14.1034H2.22419L6.98634 8.95882L10.834 14.1034H15L9.29062 6.4696L14.3535 1H13.1294L8.74366 5.7381L5.20009 1H1.03413ZM2.83439 1.90174H4.74824L13.1995 13.2016H11.2856L2.83439 1.90174Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 347 B

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

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.5" y="0.5" width="15" height="15" rx="3.5" fill="none"/>
<rect x="0.5" y="0.5" width="15" height="15" rx="3.5" stroke="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

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

@ -37,7 +37,7 @@ body {
}
.rc-virtual-list-holder-inner {
@apply !px-1.5
@apply !px-1.5;
}
.ant-layout-header {
height: var(--topbar-height) !important;
@ -51,13 +51,17 @@ main {
@apply m-0 h-full w-full bg-white;
}
.nc-input-md {
@apply !rounded-lg !py-2 !px-3 mb-1;
}
.mobile {
.nc-scrollbar-md, .nc-scrollbar-lg, .nc-scrollbar-x-md, .nc-scrollbar-dark-md, .nc-scrollbar-x-md-dark, .nc-scrollbar-x-lg {
.nc-scrollbar-md,
.nc-scrollbar-lg,
.nc-scrollbar-x-md,
.nc-scrollbar-dark-md,
.nc-scrollbar-x-md-dark,
.nc-scrollbar-x-lg {
&::-webkit-scrollbar {
width: 0px;
}
@ -116,7 +120,6 @@ main {
overflow-x: auto !important;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
@ -131,7 +134,6 @@ main {
-webkit-border-radius: 10px;
border-radius: 10px;
width: 4px;
@apply bg-gray-200;
}
@ -145,7 +147,6 @@ main {
overflow-x: hidden;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
@ -177,7 +178,6 @@ main {
overflow-x: auto !important;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
@ -192,14 +192,11 @@ main {
-webkit-border-radius: 10px;
border-radius: 10px;
width: 4px;
background-color: rgba(0, 0, 0, 0.3)
background-color: rgba(0, 0, 0, 0.3);
}
&::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.4)
background-color: rgba(0, 0, 0, 0.4);
}
}
@ -220,7 +217,6 @@ main {
-webkit-border-radius: 10px;
border-radius: 10px;
width: 8px;
@apply bg-gray-200;
}
@ -256,10 +252,10 @@ a {
@apply !w-1;
}
.rc-virtual-list-scrollbar-thumb{
.rc-virtual-list-scrollbar-thumb {
@apply !bg-gray-200;
&:hover{
&:hover {
@apply !bg-gray-300;
}
}
@ -466,8 +462,8 @@ a {
.ant-dropdown-menu-submenu {
@apply !py-0;
&.ant-dropdown-menu-submenu-popup{
@apply border-1 border-gray-200
&.ant-dropdown-menu-submenu-popup {
@apply border-1 border-gray-200;
}
.ant-dropdown-menu,
.ant-menu {
@ -545,11 +541,11 @@ a {
@apply bg-gray-300 bg-opacity-20;
}
.ant-select-item-option:hover{
.ant-select-item-option:hover {
@apply !bg-gray-100;
}
.ant-select-item-option-selected{
.ant-select-item-option-selected {
@apply !bg-white;
}
/* Hide the element with id nc-selected-item-icon */
@ -658,7 +654,7 @@ a {
}
.nc-toolbar-dropdown {
@apply !rounded-2xl;
@apply !rounded-lg;
}
input[type='number'] {
@ -712,7 +708,8 @@ input[type='number'] {
.nc-emoji {
@apply xs:(text-lg);
}
.material-symbols, .nc-icon {
.material-symbols,
.nc-icon {
@apply !xs:(text-xl -mt-0.25);
}
@ -723,12 +720,19 @@ input[type='number'] {
.nc-sidebar-node-btn:not(.nc-sidebar-expand) {
@apply !xs:(hidden);
}
.nc-sidebar-node-btn.nc-sidebar-expand {
@apply !xs:(flex-none border-1 border-gray-200 w-6.5 h-6.5 mr-1);
}
}
.nc-button.ant-btn.nc-sidebar-node-btn {
@apply opacity-0 group-hover:(opacity-100) text-gray-600 hover:(bg-gray-400 bg-opacity-20 text-gray-900) duration-100;
}
.nc-button.ant-btn.nc-sidebar-node-btn:not(.nc-sidebar-expand):not(.nc-sidebar-view-node-context-btn) {
@apply hidden group-hover:(inline-block);
}
.nc-button.ant-btn.nc-sidebar-node-btn.nc-sidebar-expand {
@apply xs:(opacity-100 hover:bg-gray-50);
@ -740,17 +744,17 @@ input[type='number'] {
.ant-message-notice-content {
@apply !rounded-md;
.ant-message-custom-content{
.ant-message-custom-content {
@apply flex items-center;
}
}
svg.nc-cell-icon, svg.nc-virtual-cell-icon {
svg.nc-cell-icon,
svg.nc-virtual-cell-icon {
@apply w-1em h-1em flex-none;
font-size: 1rem;
}
// For select type field list layout
.nc-field-layout-list {
@apply !flex !flex-col !items-start w-full !space-y-0.5 !max-w-full;
@ -786,3 +790,25 @@ svg.nc-cell-icon, svg.nc-virtual-cell-icon {
}
}
.nc-toolbar-dropdown-search-field-input {
@apply !rounded-lg;
.nc-search-icon {
@apply text-gray-400;
}
&:hover .nc-search-icon,
&.ant-input-affix-wrapper-focused .nc-search-icon {
@apply text-gray-800;
}
}
// switch - on tab focus show outline
.ant-switch:focus-visible,
.ant-switch-checked:focus-visible {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
}
.text-nowrap{
text-wrap: nowrap;
}

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

@ -52,6 +52,7 @@ declare module 'vue' {
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARate: typeof import('ant-design-vue/es')['Rate']
AResult: typeof import('ant-design-vue/es')['Result']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']

32
packages/nc-gui/components/account/HeaderWithSorter.vue

@ -0,0 +1,32 @@
<script setup lang="ts">
const { header, field, toggleSort } = defineProps<{
header: string
activeSort: { field?: string; direction?: string }
field: UsersSortType['field']
toggleSort: Function
}>()
</script>
<template>
<div class="flex items-center space-x-2 cursor-pointer text-gray-700" @click="toggleSort(field)">
<span>
{{ header }}
</span>
<div class="flex flex-col">
<GeneralIcon
icon="arrowDropUp"
class="text-sm mb-[-10px] text-[16px]"
:class="{
'text-primary': activeSort.field === field && activeSort.direction === 'asc',
}"
/>
<GeneralIcon
icon="arrowDropDown"
class="text-sm text-[16px]"
:class="{
'text-primary': activeSort.field === field && activeSort.direction === 'desc',
}"
/>
</div>
</div>
</template>

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

@ -28,7 +28,7 @@ const { user: loggedInUser } = useGlobal()
const { copy } = useCopy()
const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Org')
const { sorts, loadSorts, handleGetSortedData, toggleSort } = useUserSorts('Org')
const users = ref<UserType[]>([])
@ -198,21 +198,22 @@ const openDeleteModal = (user: UserType) => {
</div>
<div class="w-full rounded-md max-w-250 h-[calc(100%-12rem)] rounded-md overflow-hidden mt-5">
<div class="flex w-full bg-gray-50 border-1 rounded-t-md">
<div
class="py-3.5 text-gray-500 font-medium text-3.5 w-2/3 text-start pl-6 flex items-center space-x-2"
data-rec="true"
>
<span>
{{ $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('general.access') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
<LazyAccountHeaderWithSorter
class="py-3.5 text-gray-500 font-medium text-3.5 w-2/3 text-start pl-6"
:header="$t('objects.users')"
:active-sort="sorts"
field="email"
:toggle-sort="toggleSort"
/>
<LazyAccountHeaderWithSorter
class="py-3.5 text-gray-500 font-medium text-3.5 w-1/3 text-start"
:header="$t('general.access')"
:active-sort="sorts"
field="roles"
:toggle-sort="toggleSort"
/>
<div class="flex py-3.5 text-gray-500 font-medium text-3.5 w-28 justify-end mr-4" data-rec="true">
{{ $t('labels.action') }}
</div>

79
packages/nc-gui/components/account/UserMenu.vue

@ -1,79 +0,0 @@
<script lang="ts" setup>
import { iconMap } from '#imports'
import type { UsersSortType } from '~/lib'
const { field, direction, handleUserSort } = defineProps<{
field: UsersSortType['field']
direction?: UsersSortType['direction']
handleUserSort: Function
}>()
const isOpen = ref(false)
const sortUserBy = (direction?: UsersSortType['direction']) => {
handleUserSort({
field,
direction,
})
isOpen.value = false
}
</script>
<template>
<a-dropdown
v-model:visible="isOpen"
:trigger="['click']"
placement="bottomLeft"
overlay-class-name="nc-user-menu-column-operations !border-1 rounded-lg !shadow-xl"
@click.stop="isOpen = !isOpen"
>
<div>
<GeneralIcon
:icon="direction === 'asc' || direction === 'desc' ? 'sortDesc' : 'arrowDown'"
class="text-grey h-full text-grey nc-user-menu-trigger cursor-pointer outline-0 mr-2 transition-none"
:style="{ transform: direction === 'asc' ? 'rotate(180deg)' : undefined }"
/>
</div>
<template #overlay>
<NcMenu class="flex flex-col gap-1 border-gray-200 nc-user-menu-column-options">
<NcMenuItem @click="sortUserBy('asc')">
<div class="nc-column-insert-after nc-user-menu-item">
<component
:is="iconMap.sortDesc"
class="text-gray-700 !rotate-180 !w-4.25 !h-4.25"
:style="{
transform: 'rotate(180deg)',
}"
/>
<!-- Sort Ascending -->
{{ $t('general.sortAsc') }}
</div>
</NcMenuItem>
<NcMenuItem @click="sortUserBy('desc')">
<div class="nc-column-insert-before nc-user-menu-item">
<component :is="iconMap.sortDesc" class="text-gray-700 !w-4.25 !h-4.25 ml-0.5 mr-0.25" />
<!-- Sort Descending -->
{{ $t('general.sortDesc') }}
</div>
</NcMenuItem>
</NcMenu>
</template>
</a-dropdown>
</template>
<style scoped>
.nc-user-menu-item {
@apply flex items-center gap-2;
}
.nc-user-menu-column-options {
.nc-icons {
@apply !w-5 !h-5;
}
}
:deep(.ant-dropdown-menu-item) {
@apply !hover:text-black text-gray-700;
}
</style>

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

@ -9,6 +9,7 @@ import {
getMdiIcon,
inject,
parseProp,
rowHeightInPx,
useBase,
useSelectedCellKeyupListener,
} from '#imports'
@ -47,6 +48,8 @@ const rowHeight = inject(RowHeightInj, ref())
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const checkboxMeta = computed(() => {
return {
icon: {
@ -110,7 +113,9 @@ useSelectedCellKeyupListener(active, (e) => {
}"
:style="{
height:
isForm || isExpandedFormOpen || isGallery || isEditColumnMenu ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
isGrid && !isForm && !isExpandedFormOpen && !isEditColumnMenu
? `${!rowHeight || rowHeight === 1 ? rowHeightInPx['1'] - 4 : rowHeightInPx[`${rowHeight}`] - 20}px`
: undefined,
}"
:tabindex="readOnly ? -1 : 0"
@click="onClick(false, $event)"

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

@ -6,7 +6,12 @@ const props = defineProps<{
</script>
<template>
<div v-if="!props.lines || props.lines === 1" class="text-ellipsis overflow-hidden">
<span :style="{ 'word-break': 'keep-all', 'white-space': 'nowrap' }">{{ props.value || '' }}</span>
</div>
<div
v-else
:style="{
'display': '-webkit-box',
'max-width': '100%',

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

@ -116,7 +116,7 @@ onMounted(() => {
:ref="focus"
v-model="vModel"
type="number"
class="nc-cell-field h-full text-sm border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
class="nc-cell-field h-full border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
:class="isForm && !isEditColumn ? 'flex flex-1' : 'w-full'"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:disabled="readOnly"

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

@ -1,17 +1,15 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { isDateMonthFormat } from 'nocodb-sdk'
import { isDateMonthFormat, isSystemColumn } from 'nocodb-sdk'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
isDrawerOrModalExist,
onClickOutside,
onMounted,
onUnmounted,
@ -19,7 +17,6 @@ import {
ref,
useGlobal,
useI18n,
useSelectedCellKeyupListener,
watch,
} from '#imports'
@ -34,7 +31,7 @@ const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const { showNull } = useGlobal()
const { showNull, isMobileMode } = useGlobal()
const columnMeta = inject(ColumnInj, null)!
@ -46,19 +43,27 @@ const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isDateInvalid = ref(false)
const datePickerRef = ref<HTMLInputElement>()
const dateFormat = computed(() => parseProp(columnMeta?.value?.meta)?.date_format ?? 'YYYY-MM-DD')
const picker = computed(() => (isDateMonthFormat(dateFormat.value) ? 'month' : ''))
const isClearedInputMode = ref<boolean>(false)
const open = ref<boolean>(false)
const localState = computed({
get() {
if (!modelValue) {
if (!modelValue || isClearedInputMode.value) {
return undefined
}
@ -72,6 +77,8 @@ const localState = computed({
return dayjs(/^\d+$/.test(modelValue) ? +modelValue : modelValue, format)
},
set(val?: dayjs.Dayjs) {
isClearedInputMode.value = false
if (!val) {
emit('update:modelValue', null)
return
@ -85,25 +92,56 @@ const localState = computed({
if (val.isValid()) {
emit('update:modelValue', val?.format('YYYY-MM-DD'))
}
open.value = false
},
})
const open = ref<boolean>(false)
const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return
datePickerRef.value?.blur?.()
open.value = false
})
const onBlur = (e) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return
open.value = false
}
watch(
open,
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
editable.value = true
datePickerRef.value?.focus?.()
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => {
if ((e?.target as HTMLElement)?.closest(`.nc-${randomClass}`)) {
return
}
open.value = false
})
} else {
editable.value = false
isClearedInputMode.value = false
}
},
{ flush: 'post' },
)
watch(editable, (nextValue) => {
if (isGrid.value && nextValue && !open.value) {
open.value = true
}
})
const placeholder = computed(() => {
if (isForm.value && !isDateInvalid.value) {
if (
((isForm.value || isExpandedForm.value) && !isDateInvalid.value) ||
(isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(columnMeta.value) && active.value)
) {
return dateFormat.value
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional')
@ -116,113 +154,18 @@ const placeholder = computed(() => {
}
})
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (!open.value) {
// open date picker
open.value = true
} else {
// select the current day
const el = document.querySelector('.nc-picker-date.active .ant-picker-cell-selected') as HTMLButtonElement
if (el) {
el.click()
open.value = false
}
}
break
case 'Escape':
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (open.value) {
e.stopPropagation()
open.value = false
}
break
case 'ArrowLeft':
if (!localState.value) {
;(document.querySelector('.nc-picker-date.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
} else {
const prevEl = document.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.previousElementSibling as HTMLButtonElement
if (prevEl) {
prevEl.click()
} else {
// get the last td from previous tr
const prevRowLastEl = document
.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.closest('tr')
?.previousElementSibling?.querySelector('td:last-child') as HTMLButtonElement
if (prevRowLastEl) {
prevRowLastEl.click()
} else {
// go to the previous month
;(document.querySelector('.nc-picker-date.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowRight':
if (!localState.value) {
;(document.querySelector('.nc-picker-date.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
} else {
const nextEl = document.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.nextElementSibling as HTMLButtonElement
if (nextEl) {
nextEl.click()
} else {
// get the last td from previous tr
const nextRowFirstEl = document
.querySelector('.nc-picker-date.active .ant-picker-cell-selected')
?.closest('tr')
?.nextElementSibling?.querySelector('td:first-child') as HTMLButtonElement
if (nextRowFirstEl) {
nextRowFirstEl.click()
} else {
// go to the next month
;(document.querySelector('.nc-picker-date.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowUp':
if (!localState.value)
(document.querySelector('.nc-picker-date.active .ant-picker-header-super-prev-btn') as HTMLButtonElement)?.click()
break
case 'ArrowDown':
if (!localState.value)
(document.querySelector('.nc-picker-date.active .ant-picker-header-super-next-btn') as HTMLButtonElement)?.click()
break
case ';':
localState.value = dayjs(new Date())
break
}
})
const isOpen = computed(() => {
if (readOnly.value) return false
return (readOnly.value || (localState.value && isPk)) && !active.value && !editable.value ? false : open.value
})
// use the default date picker open sync only to close the picker
const updateOpen = (next: boolean) => {
if (open.value && !next) {
open.value = false
}
}
const cellClickHook = inject(CellClickHookInj, null)
const cellClickHandler = () => {
open.value = (active.value || editable.value) && !open.value
if (readOnly.value || open.value) return
open.value = active.value || editable.value
}
onMounted(() => {
@ -241,43 +184,88 @@ const clickHandler = () => {
}
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
e.stopPropagation()
}
switch (e.key) {
case ' ':
if (isSurveyForm.value) {
open.value = !open.value
case 'Enter':
open.value = !open.value
if (!open.value) {
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
}
}
break
return
case 'Escape':
if (open.value) {
open.value = false
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
}
} else {
editable.value = false
case 'Enter':
if (!isSurveyForm.value) {
open.value = !open.value
datePickerRef.value?.blur?.()
}
return
default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) {
open.value = true
}
break
}
}
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
// To prevent event listener on non active cell
if (!active.value) return
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey || !isGrid.value || isExpandedForm.value || isEditColumn.value) return
switch (e.key) {
case ';':
localState.value = dayjs(new Date())
e.preventDefault()
break
default:
if (!isOpen.value && datePickerRef.value && /^[0-9a-z]$/i.test(e.key)) {
isClearedInputMode.value = true
datePickerRef.value.focus()
editable.value = true
open.value = true
}
}
})
</script>
<template>
<a-date-picker
ref="datePickerRef"
v-model:value="localState"
:disabled="readOnly"
:picker="picker"
:tabindex="0"
:bordered="false"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:format="dateFormat"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:allow-clear="!readOnly && !isEditColumn"
:input-read-only="!!isMobileMode"
:dropdown-class-name="`${randomClass} nc-picker-date children:border-1 children:border-gray-200 ${open ? 'active' : ''} `"
:open="isOpen"
@blur="onBlur"
@click="clickHandler"
@update:open="updateOpen"
@keydown="handleKeydown"
@mouseup.stop
@mousedown.stop
>
<template #suffixIcon></template>
</a-date-picker>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template>
<style scoped>

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

@ -7,14 +7,11 @@ import {
ColumnInj,
EditColumnInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
inject,
isDrawerOrModalExist,
parseProp,
ref,
useBase,
useSelectedCellKeyupListener,
watch,
} from '#imports'
@ -29,7 +26,7 @@ const emit = defineEmits(['update:modelValue'])
const { isMssql, isXcdbBase } = useBase()
const { showNull } = useGlobal()
const { showNull, isMobileMode } = useGlobal()
const readOnly = inject(ReadonlyInj, ref(false))
@ -37,18 +34,22 @@ const active = inject(ActiveCellInj, ref(false))
const editable = inject(EditModeInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const { t } = useI18n()
const isEditColumn = inject(EditColumnInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const column = inject(ColumnInj)!
const isDateInvalid = ref(false)
const datePickerRef = ref<HTMLInputElement>()
const dateTimeFormat = computed(() => {
const dateFormat = parseProp(column?.value?.meta)?.date_format ?? dateFormats[0]
const timeFormat = parseProp(column?.value?.meta)?.time_format ?? timeFormats[0]
@ -57,14 +58,13 @@ const dateTimeFormat = computed(() => {
let localModelValue = modelValue ? dayjs(modelValue).utc().local() : undefined
const tempLocalValue = ref<dayjs.Dayjs>()
const isClearedInputMode = ref<boolean>(false)
const open = ref(false)
const localState = computed({
get() {
if (!modelValue && tempLocalValue.value) {
return tempLocalValue.value
}
if (!modelValue) {
if (!modelValue || isClearedInputMode.value) {
return undefined
}
@ -114,8 +114,10 @@ const localState = computed({
return dayjs(modelValue).utc().local()
},
set(val?: dayjs.Dayjs) {
isClearedInputMode.value = false
if (!val) {
emit('update:modelValue', null)
return
}
@ -128,8 +130,6 @@ const localState = computed({
},
})
const open = ref(false)
const isOpen = computed(() => {
if (readOnly.value) return false
@ -137,27 +137,44 @@ const isOpen = computed(() => {
})
const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return
datePickerRef.value?.blur?.()
open.value = false
})
const onBlur = (e) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return
open.value = false
}
watch(
open,
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
editable.value = true
datePickerRef.value?.focus?.()
if (!modelValue) {
tempLocalValue.value = dayjs(new Date()).utc().local()
} else {
tempLocalValue.value = undefined
}
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => {
if ((e?.target as HTMLElement)?.closest(`.nc-${randomClass}`)) {
return
}
open.value = false
})
} else {
editable.value = false
tempLocalValue.value = undefined
isClearedInputMode.value = false
}
},
{ flush: 'post' },
)
const placeholder = computed(() => {
if (isForm.value && !isDateInvalid.value) {
if (
((isForm.value || isExpandedForm.value) && !isDateInvalid.value) ||
(isGrid.value && !showNull.value && !isDateInvalid.value && !isSystemColumn(column.value) && active.value)
) {
return dateTimeFormat.value
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional')
@ -170,105 +187,18 @@ const placeholder = computed(() => {
}
})
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (!open.value) {
// open date picker
open.value = true
} else {
// click Ok button to save the currently selected date
;(document.querySelector('.nc-picker-datetime.active .ant-picker-ok button') as HTMLButtonElement)?.click()
}
break
case 'Escape':
// skip if drawer / modal is active
if (isDrawerOrModalExist()) {
return
}
if (open.value) {
e.stopPropagation()
open.value = false
}
break
case 'ArrowLeft':
if (!localState.value) {
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
} else {
const prevEl = document.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.previousElementSibling as HTMLButtonElement
if (prevEl) {
prevEl.click()
} else {
// get the last td from previous tr
const prevRowLastEl = document
.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.closest('tr')
?.previousElementSibling?.querySelector('td:last-child') as HTMLButtonElement
if (prevRowLastEl) {
prevRowLastEl.click()
} else {
// go to the previous month
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-prev-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowRight':
if (!localState.value) {
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
} else {
const nextEl = document.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.nextElementSibling as HTMLButtonElement
if (nextEl) {
nextEl.click()
} else {
// get the last td from previous tr
const nextRowFirstEl = document
.querySelector('.nc-picker-datetime.active .ant-picker-cell-selected')
?.closest('tr')
?.nextElementSibling?.querySelector('td:first-child') as HTMLButtonElement
if (nextRowFirstEl) {
nextRowFirstEl.click()
} else {
// go to the next month
;(document.querySelector('.nc-picker-datetime.active .ant-picker-header-next-btn') as HTMLButtonElement)?.click()
}
}
}
break
case 'ArrowUp':
if (!localState.value)
(document.querySelector('.nc-picker-datetime.active .ant-picker-header-super-prev-btn') as HTMLButtonElement)?.click()
break
case 'ArrowDown':
if (!localState.value)
(document.querySelector('.nc-picker-datetime.active .ant-picker-header-super-next-btn') as HTMLButtonElement)?.click()
break
case ';':
localState.value = dayjs(new Date())
break
}
})
const cellClickHook = inject(CellClickHookInj, null)
const cellClickHandler = () => {
if (readOnly.value) return
open.value = (active.value || editable.value) && !open.value
if (readOnly.value || open.value) return
open.value = active.value || editable.value
}
function okHandler(val: dayjs.Dayjs | string) {
isClearedInputMode.value = false
if (!val) {
emit('update:modelValue', null)
return
}
if (dayjs(val).isValid()) {
} else if (dayjs(val).isValid()) {
// setting localModelValue to cater NOW function in date picker
localModelValue = dayjs(val)
// send the payload in UTC format
@ -276,7 +206,12 @@ function okHandler(val: dayjs.Dayjs | string) {
}
open.value = !open.value
if (!open.value && isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
editable.value = false
}
}
onMounted(() => {
cellClickHook?.on(cellClickHandler)
})
@ -284,7 +219,14 @@ onUnmounted(() => {
cellClickHook?.on(cellClickHandler)
})
const clickHandler = () => {
const clickHandler = (e) => {
if ((e.target as HTMLElement).closest(`.nc-${randomClass} .ant-picker-clear`)) {
e.stopPropagation()
emit('update:modelValue', null)
open.value = false
return
}
if (cellClickHook) {
return
}
@ -296,42 +238,99 @@ const isColDisabled = computed(() => {
})
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
e.stopPropagation()
}
switch (e.key) {
case ' ':
if (isSurveyForm.value) {
open.value = !open.value
case 'Enter':
if (isOpen.value) {
return okHandler((e.target as HTMLInputElement).value)
} else {
open.value = true
}
break
return
case 'Escape':
if (open.value) {
open.value = false
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
}
} else {
editable.value = false
case 'Enter':
if (!isSurveyForm.value) {
open.value = !open.value
datePickerRef.value?.blur?.()
}
return
case 'Tab':
open.value = false
if (isGrid.value) {
editable.value = false
datePickerRef.value?.blur()
}
return
default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) {
open.value = true
}
break
}
}
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
// To prevent event listener on non active cell
if (!active.value) return
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey || !isGrid.value || isExpandedForm.value || isEditColumn.value) return
switch (e.key) {
case ';':
localState.value = dayjs(new Date())
e.preventDefault()
break
default:
if (!isOpen.value && datePickerRef.value && /^[0-9a-z]$/i.test(e.key)) {
isClearedInputMode.value = true
datePickerRef.value.focus()
editable.value = true
open.value = true
}
}
})
watch(editable, (nextValue) => {
if (isGrid.value && nextValue && !open.value) {
open.value = true
}
})
</script>
<template>
<a-date-picker
ref="datePickerRef"
:value="localState"
:disabled="isColDisabled"
:show-time="true"
:bordered="false"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
class="nc-cell-field nc-cell-picker-datetime !w-full !py-1 !border-none !text-current"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:format="dateTimeFormat"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:allow-clear="!isColDisabled && !isEditColumn"
:input-read-only="!!isMobileMode"
:dropdown-class-name="`${randomClass} nc-picker-datetime children:border-1 children:border-gray-200 ${open ? 'active' : ''}`"
:open="isOpen"
@blur="onBlur"
@click="clickHandler"
@ok="okHandler"
@keydown="handleKeydown"
@mouseup.stop
@mousedown.stop
>
<template #suffixIcon></template>
</a-date-picker>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template>
<style scoped>

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

@ -99,7 +99,7 @@ watch(isExpandedFormOpen, () => {
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full !text-sm"
class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full"
type="number"
:step="precision"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@ -114,7 +114,7 @@ watch(isExpandedFormOpen, () => {
@mousedown.stop
/>
<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>
<span v-else class="nc-cell-field">{{ displayValue }}</span>
</template>
<style scoped lang="scss">

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

@ -91,7 +91,7 @@ watch(
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full outline-none text-sm py-1"
class="nc-cell-field w-full outline-none py-1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@keydown.down.stop
@ -109,7 +109,7 @@ watch(
<nuxt-link
v-else-if="validEmail"
no-ref
class="py-1 text-sm underline hover:opacity-75 inline-block"
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link max-w-full"
:href="`mailto:${vModel}`"
target="_blank"
:tabindex="readOnly ? -1 : 0"

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

@ -51,7 +51,7 @@ const focus: VNodeRef = (el) =>
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="nc-cell-field 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"
type="number"
step="0.1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@ -65,7 +65,7 @@ const focus: VNodeRef = (el) =>
@mousedown.stop
/>
<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>
<span v-else class="nc-cell-field">{{ vModel }}</span>
</template>
<style scoped lang="scss">

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

@ -94,7 +94,7 @@ function onKeyDown(e: any) {
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="nc-cell-field outline-none py-1 border-none w-full h-full text-sm"
class="nc-cell-field outline-none py-1 border-none w-full h-full"
:type="inputType"
style="letter-spacing: 0.06rem"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@ -109,7 +109,7 @@ function onKeyDown(e: any) {
@mousedown.stop
/>
<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>
<span v-else class="nc-cell-field">{{ displayValue }}</span>
</template>
<style scoped lang="scss">

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

@ -23,6 +23,7 @@ import {
onMounted,
reactive,
ref,
rowHeightTruncateLines,
useBase,
useEventListener,
useMetas,
@ -413,11 +414,11 @@ const onFocus = () => {
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="text-small"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
@ -446,21 +447,26 @@ const onFocus = () => {
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-line-clamp': rowHeightTruncateLines(rowHeight),
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of selectedOpts" :key="selectedOpt.value">
<a-tag class="rounded-tag max-w-full" :color="selectedOpt.color">
<a-tag
class="rounded-tag max-w-full"
:class="{
'!my-0': !rowHeight || rowHeight === 1,
}"
:color="selectedOpt.color"
>
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
@ -496,7 +502,7 @@ const onFocus = () => {
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
:dropdown-class-name="`nc-dropdown-multi-select-cell !min-w-156px ${isOpen ? 'active' : ''}`"
@search="search"
@keydown="onKeyDown"
@focus="onFocus"
@ -516,12 +522,11 @@ const onFocus = () => {
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
@ -559,6 +564,9 @@ const onFocus = () => {
<a-tag
v-if="options.find((el) => el.title === val)"
class="rounded-tag nc-selected-option"
:class="{
'!my-0': !rowHeight || rowHeight === 1,
}"
:style="{ display: 'flex', alignItems: 'center' }"
:color="options.find((el) => el.title === val)?.color"
:closable="editAllowed && (vModel.length > 1 || !column?.rqd)"
@ -568,7 +576,7 @@ const onFocus = () => {
>
<span
:style="{
'color': tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
color: tinycolor.isReadable(options.find((el) => el.title === val)?.color || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
@ -576,9 +584,8 @@ const onFocus = () => {
: tinycolor
.mostReadable(options.find((el) => el.title === val)?.color || '#ccc', ['#0b1d05', '#fff'])
.toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
{{ val }}
</span>
@ -622,11 +629,11 @@ const onFocus = () => {
}
.rounded-tag {
@apply py-0 px-[12px] rounded-[12px];
@apply py-[0.5px] px-2 rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag" my-[2px];
@apply "rounded-tag" my-[1px];
}
:deep(.ant-tag-close-icon) {
@ -638,7 +645,7 @@ const onFocus = () => {
}
:deep(.ant-select-selection-overflow) {
@apply flex-nowrap overflow-hidden;
@apply flex-nowrap overflow-hidden max-w-[fit-content];
}
.nc-multi-select:not(.read-only) {
@ -649,7 +656,7 @@ const onFocus = () => {
}
:deep(.ant-select-selector) {
@apply !pl-0;
@apply !pl-0 flex-nowrap;
}
:deep(.ant-select-selection-search-input) {

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

@ -140,10 +140,10 @@ const onTabPress = (e: KeyboardEvent) => {
@focus="onWrapperFocus"
>
<input
v-if="!readOnly && editEnabled && (isExpandedFormOpen ? expandedEditEnabled : true)"
v-if="!readOnly && editEnabled && (isExpandedFormOpen ? expandedEditEnabled || !percentMeta.is_progress : true)"
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full !text-sm !border-none !outline-none focus:ring-0 text-base py-1"
class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1"
:type="inputType"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur"
@ -169,7 +169,7 @@ const onTabPress = (e: KeyboardEvent) => {
/>
</div>
<!-- nbsp to keep height even if vModel is zero length -->
<span v-else class="nc-cell-field">{{ vModel }}&nbsp;</span>
<span v-else class="nc-cell-field">{{ vModel }} {{ !vModel ? '&nbsp;' : '' }}</span>
</div>
</template>

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

@ -67,7 +67,7 @@ watch(
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full outline-none text-sm py-1"
class="nc-cell-field w-full outline-none py-1"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@keydown.down.stop
@ -83,7 +83,7 @@ watch(
<a
v-else-if="validPhoneNumber"
class="py-1 text-sm underline hover:opacity-75 inline-block"
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link"
:href="`tel:${vModel}`"
target="_blank"
rel="noopener noreferrer"

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

@ -22,6 +22,8 @@ const column = inject(ColumnInj)!
const readOnly = inject(ReadonlyInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const ratingMeta = computed(() => {
@ -78,7 +80,15 @@ watch(rateDomRef, () => {
:disabled="readOnly"
:count="ratingMeta.max"
:class="readOnly ? 'pointer-events-none' : ''"
:style="`color: ${ratingMeta.color}; padding: ${isExpandedFormOpen ? '0px 8px' : '0px 2px'};`"
:style="{
'color': ratingMeta.color,
'padding': isExpandedFormOpen ? '0px 8px' : '0px 2px',
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeightTruncateLines(rowHeight),
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
@keydown="onKeyPress"
>
<template #character>

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

@ -10,7 +10,15 @@ import Placeholder from '@tiptap/extension-placeholder'
import { TaskItem } from '@/helpers/dbTiptapExtensions/task-item'
import { Link } from '@/helpers/dbTiptapExtensions/links'
import type { RichTextBubbleMenuOptions } from '#imports'
import { IsExpandedFormOpenInj, IsFormInj, IsGridInj, IsSurveyFormInj, ReadonlyInj, RowHeightInj } from '#imports'
import {
IsExpandedFormOpenInj,
IsFormInj,
IsGridInj,
IsSurveyFormInj,
ReadonlyInj,
RowHeightInj,
rowHeightTruncateLines,
} from '#imports'
const props = withDefaults(
defineProps<{
@ -51,6 +59,10 @@ const isFocused = ref(false)
const keys = useMagicKeys()
const shouldShowLinkOption = computed(() => {
return isFormField.value ? isFocused.value : true
})
const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', {
@ -125,6 +137,8 @@ marked.use({ extensions: [checkListItem] })
const editorDom = ref<HTMLElement | null>(null)
const richTextLinkOptionRef = ref<HTMLElement | null>(null)
const vModel = useVModel(props, 'value', emits, { defaultValue: '' })
const tiptapExtensions = [
@ -159,7 +173,7 @@ const editor = useEditor({
emits('focus')
},
onBlur: (e) => {
if (!(e?.event?.relatedTarget as HTMLElement)?.closest('.bubble-menu, .nc-textarea-rich-editor')) {
if (!(e?.event?.relatedTarget as HTMLElement)?.closest('.bubble-menu, .nc-textarea-rich-editor, .nc-rich-text')) {
isFocused.value = false
emits('blur')
}
@ -231,13 +245,37 @@ useEventListener(
'focusout',
(e: FocusEvent) => {
const targetEl = e?.relatedTarget as HTMLElement
if (targetEl?.classList?.contains('tiptap') || !targetEl?.closest('.bubble-menu, .nc-textarea-rich-editor')) {
if (targetEl?.classList?.contains('tiptap') || !targetEl?.closest('.bubble-menu, .tippy-content, .nc-textarea-rich-editor')) {
isFocused.value = false
emits('blur')
}
},
true,
)
useEventListener(
richTextLinkOptionRef,
'focusout',
(e: FocusEvent) => {
const targetEl = e?.relatedTarget as HTMLElement
if (!targetEl && (e.target as HTMLElement)?.closest('.bubble-menu, .tippy-content, .nc-textarea-rich-editor')) return
if (!targetEl?.closest('.bubble-menu, .tippy-content, .nc-textarea-rich-editor')) {
isFocused.value = false
emits('blur')
}
},
true,
)
onClickOutside(editorDom, (e) => {
if (!isFocused.value) return
const targetEl = e?.target as HTMLElement
if (!targetEl?.closest('.bubble-menu,.tippy-content, .nc-textarea-rich-editor')) {
isFocused.value = false
emits('blur')
}
})
</script>
<template>
@ -273,7 +311,15 @@ useEventListener(
</div>
<CellRichTextSelectedBubbleMenuPopup v-if="editor && !isFormField && !isForm" :editor="editor" />
<CellRichTextLinkOptions v-if="editor" :editor="editor" />
<template v-if="shouldShowLinkOption">
<CellRichTextLinkOptions
v-if="editor"
ref="richTextLinkOptionRef"
:editor="editor"
:is-form-field="isFormField"
@blur="isFocused = false"
/>
</template>
<EditorContent
ref="editorDom"
@ -283,7 +329,7 @@ useEventListener(
'mt-2.5 flex-grow': fullMode,
'scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent': !fullMode || (!fullMode && isExpandedFormOpen),
'flex-grow': isExpandedFormOpen,
[`!overflow-hidden children:line-clamp-${rowHeight}`]:
[`!overflow-hidden nc-truncate nc-line-clamp-${rowHeightTruncateLines(rowHeight)}`]:
!fullMode && readOnly && rowHeight && !isExpandedFormOpen && !isForm,
}"
@keydown.alt.enter.stop
@ -379,6 +425,26 @@ useEventListener(
}
.nc-textarea-rich-editor {
&.nc-truncate {
.tiptap.ProseMirror {
display: -webkit-box;
max-width: 100%;
-webkit-box-orient: vertical;
word-break: break-word;
}
&.nc-line-clamp-1 .tiptap.ProseMirror {
-webkit-line-clamp: 1;
}
&.nc-line-clamp-2 .tiptap.ProseMirror {
-webkit-line-clamp: 2;
}
&.nc-line-clamp-3 .tiptap.ProseMirror {
-webkit-line-clamp: 3;
}
&.nc-line-clamp-4 .tiptap.ProseMirror {
-webkit-line-clamp: 4;
}
}
.tiptap p.is-editor-empty:first-child::before {
color: #9aa2af;
content: attr(data-placeholder);

13
packages/nc-gui/components/cell/RichText/LinkOptions.vue

@ -6,11 +6,14 @@ import type { Mark } from 'prosemirror-model'
const props = defineProps<Props>()
const emits = defineEmits(['blur'])
interface Props {
editor: Editor
isFormField?: boolean
}
const editor = computed(() => props.editor)
const { editor, isFormField } = toRefs(props)
const inputRef = ref<HTMLInputElement>()
const linkNodeMark = ref<Mark | undefined>()
@ -164,6 +167,10 @@ const onMountLinkOptions = (e) => {
e.popper.style.width = '95%'
}
}
const tabIndex = computed(() => {
return isFormField.value ? -1 : 0
})
</script>
<template>
@ -188,17 +195,20 @@ const onMountLinkOptions = (e) => {
<a-input
ref="inputRef"
v-model:value="href"
:tabindex="tabIndex"
class="nc-text-area-rich-link-option-input flex-1 !mx-0.5 !px-1.5 !py-0.5 !rounded-md z-10"
:bordered="false"
placeholder="Enter a link"
@change="onChange"
@press-enter="onInputBoxEnter"
@keydown="handleInputBoxKeyDown"
@blur="emits('blur')"
/>
</div>
<NcTooltip overlay-class-name="nc-text-area-rich-link-options">
<template #title> Open link </template>
<NcButton
:tabindex="tabIndex"
:class="{
'!text-gray-300 cursor-not-allowed': href.length === 0,
}"
@ -213,6 +223,7 @@ const onMountLinkOptions = (e) => {
<NcTooltip overlay-class-name="nc-text-area-rich-link-options">
<template #title> Delete link </template>
<NcButton
:tabindex="tabIndex"
class="!duration-0 !hover:(text-red-400 bg-red-50)"
data-testid="nc-text-area-rich-link-options-open-delete"
size="small"

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

@ -254,12 +254,6 @@ const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
isOpen.value = false
if (isForm.value) return
setTimeout(() => {
aselect.value?.$el.querySelector('.ant-select-selection-search > input').focus()
}, 100)
}
}
@ -346,11 +340,11 @@ const onFocus = () => {
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="text-small"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
@ -384,12 +378,11 @@ const onFocus = () => {
<a-tag v-if="selectedOpt" class="rounded-tag max-w-full" :color="selectedOpt.color">
<span
:style="{
'color': tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable(selectedOpt.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(selectedOpt.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
@ -423,7 +416,7 @@ const onFocus = () => {
:disabled="readOnly || !editAllowed"
:show-search="!isMobileMode && isOpen && active"
:show-arrow="hasEditRoles && !readOnly && active && (vModel === null || vModel === undefined)"
:dropdown-class-name="`nc-dropdown-single-select-cell !min-w-200px ${isOpen && active ? 'active' : ''}`"
:dropdown-class-name="`nc-dropdown-single-select-cell !min-w-156px ${isOpen && active ? 'active' : ''}`"
:dropdown-match-select-width="true"
@select="onSelect"
@keydown="onKeydown($event)"
@ -442,12 +435,11 @@ const onFocus = () => {
<a-tag class="rounded-tag max-w-full" :color="op.color">
<span
:style="{
'color': tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable(op.color || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable(op.color || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
<NcTooltip class="truncate max-w-full" show-on-truncate-only>
<template #title>
@ -482,11 +474,11 @@ const onFocus = () => {
<style scoped lang="scss">
.rounded-tag {
@apply py-0 px-[12px] rounded-[12px];
@apply py-[1px] px-2 rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag" my-[2px];
@apply "rounded-tag";
}
:deep(.ant-select-clear) {

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

@ -6,6 +6,7 @@ import {
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsGridInj,
ReadonlyInj,
RowHeightInj,
computed,
@ -36,6 +37,8 @@ const rowHeight = inject(RowHeightInj, ref(1 as const))
const isForm = inject(IsFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const { showNull } = useGlobal()
@ -208,7 +211,7 @@ watch(inputWrapperRef, () => {
class="flex flex-row w-full long-text-wrapper"
:class="{
'min-h-10': rowHeight !== 1 || isExpandedFormOpen,
'min-h-9': rowHeight === 1 && !isExpandedFormOpen,
'min-h-5.5': rowHeight === 1 && !isExpandedFormOpen,
'h-full w-full': isForm,
}"
>
@ -240,8 +243,8 @@ watch(inputWrapperRef, () => {
'nc-readonly-rich-text-sort-height': rowHeight === 1 && !isExpandedFormOpen && !isForm,
}"
:style="{
maxHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
minHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${25 * (rowHeight || 1)}px`,
maxHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${21 * rowHeightTruncateLines(rowHeight)}px`,
minHeight: isForm ? undefined : isExpandedFormOpen ? `${height}px` : `${21 * rowHeightTruncateLines(rowHeight)}px`,
}"
@dblclick="onExpand"
@keydown.enter="onExpand"
@ -281,11 +284,12 @@ watch(inputWrapperRef, () => {
<LazyCellClampedText
v-else-if="rowHeight"
:value="vModel"
:lines="rowHeight"
class="nc-text-area-clamped-text my-auto"
:lines="rowHeightTruncateLines(rowHeight)"
class="nc-text-area-clamped-text"
:style="{
'word-break': 'break-word',
'max-height': `${25 * (rowHeight || 1)}px`,
'max-height': `${25 * rowHeightTruncateLines(rowHeight)}px`,
'my-auto': rowHeightTruncateLines(rowHeight) === 1,
}"
@click="onTextClick"
/>
@ -295,12 +299,28 @@ watch(inputWrapperRef, () => {
<NcTooltip
v-if="!isVisible && !isForm"
placement="bottom"
class="!absolute top-1 hidden nc-text-area-expand-btn group-hover:block z-3"
:class="isForm ? 'right-1' : 'right-0'"
class="!absolute !hidden nc-text-area-expand-btn group-hover:block z-3"
:class="{
'right-1': isForm,
'right-0': !isForm,
'top-0': isGrid && !isExpandedFormOpen && !isForm && !(!rowHeight || rowHeight === 1),
'top-1': !(isGrid && !isExpandedFormOpen && !isForm),
}"
:style="
isGrid && !isExpandedFormOpen && !isForm && (!rowHeight || rowHeight === 1)
? { top: '50%', transform: 'translateY(-50%)' }
: undefined
"
>
<template #title>{{ $t('title.expand') }}</template>
<NcButton type="secondary" size="xsmall" data-testid="attachment-cell-file-picker-button" @click.stop="onExpand">
<component :is="iconMap.expand" class="transform group-hover:(!text-grey-800 ) scale-120 text-gray-700 text-xs" />
<NcButton
type="secondary"
size="xsmall"
data-testid="attachment-cell-file-picker-button"
class="!p-0 !w-5 !h-5 !min-w-[fit-content]"
@click.stop="onExpand"
>
<component :is="iconMap.expand" class="transform group-hover:(!text-grey-800) text-gray-700 text-xs" />
</NcButton>
</NcTooltip>
</div>
@ -381,9 +401,9 @@ textarea:focus {
:deep(.ProseMirror) {
@apply !pt-0;
}
&.nc-readonly-rich-text-sort-height {
@apply mt-2;
}
// &.nc-readonly-rich-text-sort-height {
// @apply mt-1;
// }
}
}
</style>

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

@ -1,17 +1,7 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import {
ActiveCellInj,
EditColumnInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
inject,
onClickOutside,
useBase,
useSelectedCellKeyupListener,
watch,
} from '#imports'
import { isSystemColumn } from 'nocodb-sdk'
import { ActiveCellInj, EditColumnInj, IsFormInj, ReadonlyInj, inject, onClickOutside, useBase, watch } from '#imports'
interface Props {
modelValue?: string | null | undefined
@ -24,7 +14,7 @@ const emit = defineEmits(['update:modelValue'])
const { isMysql } = useBase()
const { showNull } = useGlobal()
const { showNull, isMobileMode } = useGlobal()
const readOnly = inject(ReadonlyInj, ref(false))
@ -34,21 +24,29 @@ const editable = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const column = inject(ColumnInj)!
const dateFormat = isMysql(column.value.source_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
const isTimeInvalid = ref(false)
const dateFormat = isMysql(column.value.source_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'
const datePickerRef = ref<HTMLInputElement>()
const isClearedInputMode = ref<boolean>(false)
const { t } = useI18n()
const open = ref(false)
const localState = computed({
get() {
if (!modelValue) {
if (!modelValue || isClearedInputMode.value) {
return undefined
}
let dateTime = dayjs(modelValue)
@ -67,6 +65,7 @@ const localState = computed({
return dateTime
},
set(val?: dayjs.Dayjs) {
isClearedInputMode.value = false
if (!val) {
emit('update:modelValue', null)
return
@ -80,23 +79,51 @@ const localState = computed({
},
})
const open = ref(false)
const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return
datePickerRef.value?.blur?.()
open.value = false
})
const onBlur = (e) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return
open.value = false
}
watch(
open,
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
editable.value = true
datePickerRef.value?.focus?.()
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => {
if ((e?.target as HTMLElement)?.closest(`.nc-${randomClass}`)) {
return
}
open.value = false
})
} else {
editable.value = false
isClearedInputMode.value = false
}
},
{ flush: 'post' },
)
watch(editable, (nextValue) => {
if (isGrid.value && nextValue && !open.value) {
open.value = true
}
})
const placeholder = computed(() => {
if (isForm.value && !isTimeInvalid.value) {
if (
((isForm.value || isExpandedForm.value) && !isTimeInvalid.value) ||
(isGrid.value && !showNull.value && !isTimeInvalid.value && !isSystemColumn(column.value) && active.value)
) {
return 'HH:mm'
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional')
@ -115,40 +142,71 @@ const isOpen = computed(() => {
return (readOnly.value || (localState.value && isPk)) && !active.value && !editable.value ? false : open.value
})
const handleKeydown = (e: KeyboardEvent) => {
switch (e.key) {
case ' ':
if (isSurveyForm.value) {
open.value = !open.value
}
break
const clickHandler = () => {
if (readOnly.value || open.value) return
open.value = active.value || editable.value
}
case 'Enter':
if (!isSurveyForm.value) {
open.value = !open.value
}
break
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
e.stopPropagation()
}
}
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
break
open.value = !open.value
if (!open.value) {
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
}
}
return
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
}
} else {
editable.value = false
datePickerRef.value?.blur?.()
}
return
default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) {
open.value = true
}
}
}
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
// To prevent event listener on non active cell
if (!active.value) return
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey || !isGrid.value || isExpandedForm.value || isEditColumn.value) return
switch (e.key) {
case ';':
localState.value = dayjs(new Date())
e.preventDefault()
break
default:
if (!isOpen.value && datePickerRef.value && /^[0-9a-z]$/i.test(e.key)) {
isClearedInputMode.value = true
datePickerRef.value.focus()
editable.value = true
open.value = true
}
}
})
</script>
<template>
<a-time-picker
ref="datePickerRef"
v-model:value="localState"
:tabindex="0"
:disabled="readOnly"
@ -157,18 +215,22 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
use12-hours
format="HH:mm"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:allow-clear="!readOnly && !isPk && !isEditColumn"
:input-read-only="!!isMobileMode"
:open="isOpen"
:popup-class-name="`${randomClass} nc-picker-time children:border-1 children:border-gray-200 ${open ? 'active' : ''}`"
@blur="onBlur"
@keydown="handleKeydown"
@click="open = (active || editable) && !open"
@click="clickHandler"
@ok="open = !open"
@mouseup.stop
@mousedown.stop
>
<template #suffixIcon></template>
</a-time-picker>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template>
<style scoped>

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

@ -104,7 +104,7 @@ watch(
:ref="focus"
v-model="vModel"
:placeholder="isEditColumn ? $t('labels.enterDefaultUrlOptional') : ''"
class="nc-cell-field outline-none text-sm w-full py-1 bg-transparent h-full"
class="nc-cell-field outline-none w-full py-1 bg-transparent h-full"
@blur="editEnabled = false"
@keydown.down.stop
@keydown.left.stop
@ -121,7 +121,7 @@ watch(
v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch
no-rel
class="py-1 z-3 text-sm underline hover:opacity-75"
class="py-1 z-3 underline hover:opacity-75 nc-cell-field-link max-w-full"
:to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0"
@ -133,7 +133,7 @@ watch(
v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay"
no-prefetch
no-rel
class="py-1 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 nc-cell-field-link max-w-full"
:to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0"

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

@ -20,6 +20,7 @@ import {
isDrawerOrModalExist,
onMounted,
ref,
rowHeightTruncateLines,
useEventListener,
useRoles,
useSelectedCellKeyupListener,
@ -343,12 +344,11 @@ const filterOption = (input: string, option: any) => {
<a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="flex items-stretch gap-2"
class="flex items-stretch gap-2 text-small"
>
<div>
<GeneralUserIcon
@ -393,22 +393,27 @@ const filterOption = (input: string, option: any) => {
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-line-clamp': rowHeightTruncateLines(rowHeight),
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of vModel" :key="selectedOpt.value">
<a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<a-tag
class="rounded-tag max-w-full !pl-0"
:class="{
'!my-0': !rowHeight || rowHeight === 1,
}"
color="'#ccc'"
>
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
<div class="flex-none">
<GeneralUserIcon
@ -452,7 +457,7 @@ const filterOption = (input: string, option: any) => {
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-user-select-cell !min-w-200px ${isOpen ? 'active' : ''}`"
:dropdown-class-name="`nc-dropdown-user-select-cell !min-w-156px ${isOpen ? 'active' : ''}`"
:filter-option="filterOption"
@search="search"
@keydown.stop
@ -468,16 +473,21 @@ const filterOption = (input: string, option: any) => {
:class="`nc-select-option-${column.title}-${op.email}`"
@click.stop
>
<a-tag class="rounded-tag max-w-full !pl-0" color="'#ccc'">
<a-tag
class="rounded-tag max-w-full !pl-0"
:class="{
'!my-0': !rowHeight || rowHeight === 1,
}"
color="'#ccc'"
>
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
color: tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
<div>
<GeneralUserIcon
@ -511,6 +521,9 @@ const filterOption = (input: string, option: any) => {
<a-tag
v-if="options.find((el) => el.id === val)"
class="rounded-tag nc-selected-option !pl-0"
:class="{
'!my-0': !rowHeight || rowHeight === 1,
}"
:style="{ display: 'flex', alignItems: 'center' }"
color="'#ccc'"
:closable="editAllowed && ((vModel?.length ?? 0) > 1 || !column?.rqd)"
@ -520,16 +533,15 @@ const filterOption = (input: string, option: any) => {
>
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', {
color: tinycolor.isReadable('#ccc' || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
class="flex items-stretch gap-2"
:class="{ 'text-sm': isKanban }"
:class="{ 'text-sm': isKanban, 'text-small': !isKanban }"
>
<div>
<GeneralUserIcon
@ -581,11 +593,11 @@ const filterOption = (input: string, option: any) => {
}
.rounded-tag {
@apply bg-gray-200 py-0 px-[12px] rounded-[12px];
@apply bg-gray-200 px-2 rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag" my-[2px];
@apply "rounded-tag" my-[1px];
}
:deep(.ant-tag-close-icon) {
@ -597,7 +609,7 @@ const filterOption = (input: string, option: any) => {
}
:deep(.ant-select-selection-overflow) {
@apply flex-nowrap overflow-hidden;
@apply flex-nowrap overflow-hidden max-w-[fit-content];
}
.nc-user-select:not(.read-only) {
@ -608,10 +620,13 @@ const filterOption = (input: string, option: any) => {
}
:deep(.ant-select-selector) {
@apply !pl-0;
@apply !pl-0 flex-nowrap;
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
}
:deep(.nc-user-avatar) {
@apply min-h-4.2;
}
</style>

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

@ -1,18 +1,7 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import {
ActiveCellInj,
EditColumnInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
onClickOutside,
ref,
useSelectedCellKeyupListener,
watch,
} from '#imports'
import { isSystemColumn } from 'nocodb-sdk'
import { ActiveCellInj, EditColumnInj, IsFormInj, ReadonlyInj, computed, inject, onClickOutside, ref, watch } from '#imports'
interface Props {
modelValue?: number | string | null
@ -23,7 +12,9 @@ const { modelValue, isPk = false } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { showNull } = useGlobal()
const { showNull, isMobileMode } = useGlobal()
const column = inject(ColumnInj, null)!
const readOnly = inject(ReadonlyInj, ref(false))
@ -33,17 +24,25 @@ const editable = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isYearInvalid = ref(false)
const datePickerRef = ref<HTMLInputElement>()
const isClearedInputMode = ref<boolean>(false)
const { t } = useI18n()
const open = ref<boolean>(false)
const localState = computed({
get() {
if (!modelValue) {
if (!modelValue || isClearedInputMode.value) {
return undefined
}
@ -56,6 +55,8 @@ const localState = computed({
return yearDate
},
set(val?: dayjs.Dayjs) {
isClearedInputMode.value = false
if (!val) {
emit('update:modelValue', null)
return
@ -64,26 +65,56 @@ const localState = computed({
if (val?.isValid()) {
emit('update:modelValue', val.format('YYYY'))
}
open.value = false
},
})
const open = ref<boolean>(false)
const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
onClickOutside(datePickerRef, (e) => {
if ((e.target as HTMLElement)?.closest(`.${randomClass}`)) return
datePickerRef.value?.blur?.()
open.value = false
})
const onBlur = (e) => {
if ((e?.relatedTarget as HTMLElement)?.closest(`.${randomClass}`)) return
open.value = false
}
watch(
open,
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, () => (open.value = false))
editable.value = true
datePickerRef.value?.focus?.()
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => {
if ((e?.target as HTMLElement)?.closest(`.nc-${randomClass}`)) {
return
}
open.value = false
})
} else {
editable.value = false
isClearedInputMode.value = false
}
},
{ flush: 'post' },
)
watch(editable, (nextValue) => {
if (isGrid.value && nextValue && !open.value) {
open.value = true
}
})
const placeholder = computed(() => {
if (isForm.value && !isYearInvalid.value) {
if (
((isForm.value || isExpandedForm.value) && !isYearInvalid.value) ||
(isGrid.value && !showNull.value && !isYearInvalid.value && !isSystemColumn(column.value) && active.value)
) {
return 'YYYY'
} else if (isEditColumn.value && (modelValue === '' || modelValue === null)) {
return t('labels.optional')
@ -98,62 +129,96 @@ const placeholder = computed(() => {
const isOpen = computed(() => {
if (readOnly.value) return false
return (readOnly.value || (localState.value && isPk)) && !active.value && !editable.value ? false : open.value
})
const clickHandler = () => {
if (readOnly.value || open.value) return
open.value = active.value || editable.value
}
const handleKeydown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
e.stopPropagation()
}
switch (e.key) {
case ' ':
if (isSurveyForm.value) {
open.value = !open.value
case 'Enter':
open.value = !open.value
if (!open.value) {
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
}
}
break
case 'Enter':
if (!isSurveyForm.value) {
open.value = !open.value
return
case 'Escape':
if (open.value) {
open.value = false
editable.value = false
if (isGrid.value && !isExpandedForm.value && !isEditColumn.value) {
datePickerRef.value?.blur?.()
}
} else {
editable.value = false
datePickerRef.value?.blur?.()
}
return
default:
if (!open.value && /^[0-9a-z]$/i.test(e.key)) {
open.value = true
}
break
}
}
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
// To prevent event listener on non active cell
if (!active.value) return
if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey || !isGrid.value || isExpandedForm.value || isEditColumn.value) return
switch (e.key) {
case 'Enter':
e.stopPropagation()
open.value = true
case ';':
localState.value = dayjs(new Date())
e.preventDefault()
break
case 'Escape':
if (open.value) {
e.stopPropagation()
open.value = false
default:
if (!isOpen.value && datePickerRef.value && /^[0-9a-z]$/i.test(e.key)) {
isClearedInputMode.value = true
datePickerRef.value.focus()
editable.value = true
open.value = true
}
break
}
})
</script>
<template>
<a-date-picker
ref="datePickerRef"
v-model:value="localState"
:disabled="readOnly"
:tabindex="0"
picker="year"
:bordered="false"
class="nc-cell-field !w-full !py-1 !border-none !text-current"
:class="{ 'nc-null': modelValue === null && showNull }"
:class="[`nc-${randomClass}`, { 'nc-null': modelValue === null && showNull }]"
:placeholder="placeholder"
:allow-clear="(!readOnly && !localState && !isPk) || isEditColumn"
:input-read-only="true"
:allow-clear="!readOnly && !isPk"
:input-read-only="!!isMobileMode"
:open="isOpen"
:dropdown-class-name="`${randomClass} nc-picker-year children:border-1 children:border-gray-200 ${open ? 'active' : ''}`"
@blur="onBlur"
@keydown="handleKeydown"
@click="open = (active || editable) && !open"
@change="open = (active || editable) && !open"
@ok="open = !open"
@click="clickHandler"
@mouseup.stop
@mousedown.stop
>
<template #suffixIcon></template>
</a-date-picker>
<div v-if="!editable && isGrid" class="absolute inset-0 z-90 cursor-pointer"></div>
</template>
<style scoped>

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

@ -8,6 +8,7 @@ import {
DropZoneRef,
IsExpandedFormOpenInj,
IsGalleryInj,
IsGridInj,
IsKanbanInj,
IsSurveyFormInj,
RowHeightInj,
@ -52,6 +53,8 @@ const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const { isSharedForm } = useSmartsheetStoreOrThrow()!
const { isMobileMode } = useGlobal()
@ -197,7 +200,12 @@ const keydownSpace = (e: KeyboardEvent) => {
<div
ref="attachmentCellRef"
:style="{
height: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
height:
isForm || isExpandedForm
? undefined
: `max(${!rowHeight || rowHeight === 1 ? rowHeightInPx['1'] - 10 : rowHeightInPx[`${rowHeight}`] - 18}px, ${
isGrid ? '22px' : '32px'
})`,
}"
class="nc-attachment-cell relative flex color-transition flex items-center w-full xs:(min-h-12 max-h-32)"
:class="{ 'justify-center': !active, 'justify-between': active, 'px-2': isExpandedForm }"
@ -236,15 +244,15 @@ const keydownSpace = (e: KeyboardEvent) => {
<div
v-if="active || !visibleItems.length || (isForm && visibleItems.length)"
class="flex items-center gap-1 xs:(w-full min-w-12 h-8 justify-center)"
class="flex items-center gap-1 xs:(w-full min-w-12 h-7 justify-center)"
>
<MaterialSymbolsAttachFile
class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-[0.75rem]"
class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-tiny"
/>
<div
v-if="!visibleItems.length"
data-rec="true"
class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-xs xs:(justify-center rounded-lg text-sm)"
class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-tiny xs:(justify-center rounded-lg text-sm)"
>
{{ $t('activity.addFiles') }}
</div>
@ -257,10 +265,14 @@ const keydownSpace = (e: KeyboardEvent) => {
<template v-if="visibleItems.length">
<div
ref="sortableRef"
:class="{ 'justify-center': !isExpandedForm && !isGallery && !isKanban }"
class="flex cursor-pointer w-full items-center flex-wrap gap-2 py-1.5 scrollbar-thin-dull overflow-hidden mt-0 items-start"
:class="{
'justify-center': !isExpandedForm && !isGallery && !isKanban,
'py-1': rowHeight === 1 && !isForm && !isExpandedForm,
'py-1.5': rowHeight !== 1 || isForm || isExpandedForm,
}"
class="nc-attachment-wrapper flex cursor-pointer w-full items-center flex-wrap gap-2 scrollbar-thin-dull overflow-hidden mt-0 items-start"
:style="{
maxHeight: isForm || isExpandedForm ? undefined : `max(${(rowHeight || 1) * 1.8}rem, 41px)`,
maxHeight: isForm || isExpandedForm ? undefined : `max(100%, ${isGrid ? '22px' : '32px'})`,
}"
>
<template v-for="(item, i) of visibleItems" :key="item.url || item.title">
@ -278,8 +290,10 @@ const keydownSpace = (e: KeyboardEvent) => {
:alt="item.title || `#${i}`"
class="rounded"
:class="{
'h-7.5 w-8.8': rowHeight === 1,
'h-11.5 w-12.8': rowHeight === 2,
'h-5.5': !isGrid && (!rowHeight || rowHeight === 1),
'h-4.5': isGrid && (!rowHeight || rowHeight === 1),
'w-8.8': rowHeight === 1,
'h-8 w-12.8': rowHeight === 2,
'h-16.8 w-20.8': rowHeight === 4,
'h-20.8 !w-30': isForm || isExpandedForm || rowHeight === 6,
}"
@ -307,12 +321,12 @@ const keydownSpace = (e: KeyboardEvent) => {
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<NcTooltip v-else placement="bottom">
<NcTooltip v-else placement="bottom" class="flex">
<template #title> {{ $t('activity.viewAttachment') }}</template>
<component
:is="iconMap.expand"
class="transform dark:(!text-white) group-hover:(!text-grey-800 scale-120) text-gray-500 text-[0.75rem]"
class="flex-none transform dark:(!text-white) group-hover:(!text-grey-800 scale-120) text-gray-500 text-[0.75rem]"
@click.stop="onExpand"
/>
</NcTooltip>
@ -327,7 +341,7 @@ const keydownSpace = (e: KeyboardEvent) => {
.nc-cell {
.nc-attachment-cell {
.nc-attachment {
@apply min-h-[1.8rem] min-w-[1.8rem] !ring-1 !ring-gray-300 !rounded;
@apply min-h-5.5 min-w-[1.8rem] !ring-1 !ring-gray-300 !rounded;
}
.ghost,

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

@ -1,9 +1,7 @@
<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 ComputedRef, type VNode, iconMap, onClickOutside, useCommandPalette } from '#imports'
import type { CommandPaletteType } from '~/lib'
interface CmdAction {
@ -24,7 +22,6 @@ const props = defineProps<{
open: boolean
data: CmdAction[]
scope?: string
placeholder?: string
hotkey?: string
loadTemporaryScope?: (scope: { scope: string; data: any }) => void
setActiveCmdView: (cmd: CommandPaletteType) => void
@ -48,6 +45,8 @@ const { user } = useGlobal()
const selected = ref<string>()
const { cmdPlaceholder } = useCommandPalette()
const formattedData: ComputedRef<(CmdAction & { weight: number })[]> = computed(() => {
const rt: (CmdAction & { weight: number })[] = []
for (const el of props.data) {
@ -69,7 +68,8 @@ const nestedScope = computed(() => {
const rt = []
let parent = activeScope.value
while (parent !== 'root') {
const parentEl = formattedData.value.find((el) => el.id === parent)
const parentId = parent.startsWith('ws-') ? `ws-nav-${parent.split('-')[1]}` : parent
const parentEl = formattedData.value.find((el) => el.id === parentId)
rt.push({
id: parent,
label: parentEl?.title,
@ -318,45 +318,64 @@ defineExpose({
<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" />
<GeneralIcon class="h-4 w-4 text-gray-500" icon="search" />
<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"
class="flex items-center"
@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 }}
<div
class="text-gray-600 text-sm cursor-pointer flex gap-2 px-2 py-1 items-center justify-center font-medium capitalize"
>
<GeneralWorkspaceIcon
v-if="el.icon && el.id.startsWith('ws')"
:workspace="{
id: el.id.split('-')[1],
}"
hide-label
size="small"
/>
<component
:is="(iconMap as any)[el.icon]"
v-else-if="el.icon && typeof el.icon === 'string' && (iconMap as any)[el.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',
'!text-maroon-500': el.icon === 'calendar',
}"
class="cmdk-action-icon"
/>
<div v-else-if="el.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
<LazyGeneralEmojiPicker :emoji="el.icon" class="!text-sm !h-4 !w-4" readonly size="small" />
</div>
<span
class="text-ellipsis truncate capitalize max-w-16"
style="word-break: keep-all; white-space: nowrap; display: inline"
>
<NcTooltip show-on-truncate-only>
<template #title>
{{ el.label }}
</template>
<span class="text-ellipsis max-w-16">
{{ el.label }}
</span>
</NcTooltip>
</span>
</a-tooltip>
</div>
<span class="text-gray-400 text-sm font-medium pl-1">/</span>
<span class="text-gray-700 text-sm pl-1 font-medium">/</span>
</div>
<input
ref="cmdInputEl"
v-model="cmdInput"
class="cmdk-input"
type="text"
:placeholder="props.placeholder"
:placeholder="cmdPlaceholder"
@input="selectFirstAction"
/>
</div>
@ -386,7 +405,15 @@ defineExpose({
@click="fireAction(act)"
>
<div class="cmdk-action-content w-full">
<template v-if="title === 'Bases' || act.icon === 'project'">
<GeneralWorkspaceIcon
v-if="act.icon && act.id.startsWith('ws')"
:workspace="{
id: act.id.split('-')[2],
}"
class="mr-2"
size="small"
/>
<template v-else-if="title === 'Bases' || act.icon === 'project'">
<GeneralBaseIconColorPicker :key="act.iconColor" :model-value="act.iconColor" type="database" readonly>
</GeneralBaseIconColorPicker>
</template>
@ -400,6 +427,7 @@ defineExpose({
'!text-purple-500': act.icon === 'form',
'!text-[#FF9052]': act.icon === 'kanban',
'!text-pink-500': act.icon === 'gallery',
'!text-maroon-500': act.icon === 'calendar',
}"
/>
<div v-else-if="act.icon" class="cmdk-action-icon max-w-4 flex items-center justify-center">
@ -492,21 +520,21 @@ defineExpose({
}
.cmdk-input-wrapper {
@apply py-2 px-4 flex items-center gap-2;
@apply py-2 px-4 gap-1 flex items-center;
}
.cmdk-input {
@apply text-sm;
@apply text-sm pl-2;
flex-grow: 1;
flex-shrink: 0;
margin: 0px;
margin: 0;
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;
caret-color: #3366ff;
color: rgb(60, 65, 73);
}
@ -525,8 +553,8 @@ defineExpose({
.cmdk-actions {
max-height: 310px;
margin: 0px;
padding: 0.5em 0px;
margin: 0;
padding: 0;
list-style: none;
scroll-behavior: smooth;
overflow: auto;
@ -553,7 +581,7 @@ defineExpose({
border-left: 4px solid transparent;
.cmdk-keyboard {
display: hidden;
display: none;
}
&.selected {

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

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

2
packages/nc-gui/components/dashboard/Sidebar/Header.vue

@ -49,7 +49,7 @@ const showSidebarBtn = computed(() => !(isMobileMode.value && !activeViewTitleOr
<GeneralIcon
v-else
icon="doubleLeftArrow"
class="duration-150 transition-all !text-lg -mt-0.5"
class="duration-150 transition-all !text-lg -mt-0.5 !text-gray-500/75"
:class="{
'transform rotate-180': !isLeftSidebarOpen,
}"

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

@ -1,26 +1,12 @@
<script lang="ts" setup>
import {
computed,
message,
navigateTo,
onMounted,
ref,
storeToRefs,
useCopy,
useGlobal,
useSidebarStore,
useUsers,
watch,
} from '#imports'
import { computed, navigateTo, onMounted, ref, storeToRefs, useGlobal, useSidebarStore, useUsers, watch } from '#imports'
const { user, signOut, token, appInfo } = useGlobal()
const { user, signOut, appInfo } = useGlobal()
// So watcher in users store is triggered
useUsers()
const { leftSidebarState } = storeToRefs(useSidebarStore())
const { copy } = useCopy(true)
const name = computed(() => user.value?.display_name?.trim())
const isMenuOpen = ref(false)
@ -34,12 +20,14 @@ const { isMobileMode } = useGlobal()
const logout = async () => {
isLoggingOut.value = true
try {
const isSsoUser = !!(user?.value as any)?.sso_client_id
await signOut(false)
// No need as all stores are cleared on signout
// await clearWorkspaces()
await navigateTo('/signin')
await navigateTo(isSsoUser ? '/sso' : '/signin')
} catch (e) {
console.error(e)
} finally {
@ -47,16 +35,6 @@ const logout = async () => {
}
}
const onCopy = async () => {
try {
await copy(token.value!)
isAuthTokenCopied.value = true
} catch (e: any) {
console.error(e)
message.error(e.message)
}
}
watch(isMenuOpen, () => {
if (isAuthTokenCopied.value) {
isAuthTokenCopied.value = false
@ -78,17 +56,17 @@ onMounted(() => {
</script>
<template>
<div class="flex w-full flex-col p-1 border-gray-200 gap-y-1">
<div class="flex w-full flex-col py-0.9 px-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"
class="flex flex-row py-1 px-3 gap-x-2 items-center hover:bg-gray-200 rounded-lg cursor-pointer h-8"
data-testid="nc-sidebar-userinfo"
>
<GeneralUserIcon :email="user?.email" size="base" :name="user?.display_name" />
<GeneralUserIcon :email="user?.email" size="auto" :name="user?.display_name" />
<div class="flex truncate">
{{ name ? name : user?.email }}
</div>
<GeneralIcon icon="arrowUp" class="!min-w-5" />
<GeneralIcon icon="chevronDown" class="flex-none !min-w-5 transform rotate-180 !text-gray-500" />
</div>
<template #overlay>
<NcMenu data-testid="nc-sidebar-userinfo">
@ -99,16 +77,6 @@ onMounted(() => {
<span class="menu-btn"> {{ $t('general.logout') }}</span>
</div>
</NcMenuItem>
<template v-if="!isMobileMode">
<NcMenuItem @click="onCopy">
<div v-e="['c:auth-token:copy']" class="flex gap-2 items-center">
<GeneralIcon v-if="isAuthTokenCopied" icon="check" class="group-hover:text-black menu-icon" />
<GeneralIcon v-else icon="copy" class="menu-icon" />
<template v-if="isAuthTokenCopied"> {{ $t('title.copiedAuthToken') }} </template>
<template v-else> {{ $t('title.copyAuthToken') }} </template>
</div>
</NcMenuItem>
</template>
<NcDivider />
<a
v-e="['c:nocodb:discord']"
@ -118,7 +86,7 @@ onMounted(() => {
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="discord" />
<GeneralIcon class="social-icon" icon="ncDiscord" />
<span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span>
</NcMenuItem>
</a>
@ -130,7 +98,7 @@ onMounted(() => {
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="reddit" />
<GeneralIcon class="social-icon" icon="ncReddit" />
<span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
</NcMenuItem>
</a>
@ -142,7 +110,7 @@ onMounted(() => {
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper group">
<GeneralIcon class="text-gray-500 group-hover:text-gray-800 my-0.5" icon="twitter" />
<GeneralIcon class="social-icon text-gray-500 group-hover:text-gray-800" icon="ncTwitter" />
<span class="menu-btn"> {{ $t('labels.twitter') }} </span>
</NcMenuItem>
</a>
@ -181,7 +149,7 @@ onMounted(() => {
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="help" class="menu-icon mt-0.5" />
<GeneralIcon icon="ncHelp" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.forum') }} </span>
</NcMenuItem>
</a>
@ -194,23 +162,24 @@ onMounted(() => {
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="doc" class="menu-icon mt-0.5" />
<GeneralIcon icon="file" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.docs') }} </span>
</NcMenuItem>
</a>
<NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="settings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link>
</template>
</NcMenu>
</template>
</NcDropdown>
<template v-if="isMobileMode"></template>
<div v-else-if="appInfo.ee" class="text-gray-500 text-xs pl-3 mt-1">© 2023 NocoDB. Inc</div>
<template v-if="isMobileMode || appInfo.ee"></template>
<div v-else class="flex flex-row w-full justify-between pt-0.5 truncate">
<GeneralJoinCloud />
</div>
@ -222,9 +191,8 @@ onMounted(() => {
line-height: 1.5;
}
.menu-icon {
@apply !min-h-4.5;
line-height: 1rem;
font-size: 1.125rem;
@apply w-4 h-4;
font-size: 1rem;
}
:deep(.ant-popover-inner-content) {
@ -232,7 +200,7 @@ onMounted(() => {
}
.social-icon {
@apply my-0.5;
@apply my-0.5 w-4 h-4 stroke-transparent;
// Make icon black and white
filter: grayscale(100%);

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

@ -163,7 +163,7 @@ async function onOpenModal({
</div>
<GeneralLoader v-if="toBeCreateType === ViewTypes.CALENDAR && isViewListLoading" />
<GeneralIcon v-else class="text-brand-400" icon="plus" />
<GeneralIcon v-else class="plus" icon="plus" />
</div>
</NcMenuItem>
</NcMenu>
@ -181,7 +181,7 @@ async function onOpenModal({
}
.plus {
@apply text-brand-400;
@apply text-gray-500;
}
</style>

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

@ -4,7 +4,6 @@ import { message } from 'ant-design-vue'
import { stringifyRolesObj } from 'nocodb-sdk'
import type { BaseType, SourceType, TableType } from 'nocodb-sdk'
import { LoadingOutlined } from '@ant-design/icons-vue'
import { useTitle } from '@vueuse/core'
import {
NcProjectType,
ProjectInj,
@ -109,6 +108,8 @@ const keys = ref<Record<string, number>>({})
const isTableDeleteDialogVisible = ref(false)
const isProjectDeleteDialogVisible = ref(false)
const { refreshViewTabTitle } = useViewsStore()
// If only base is open, i.e in case of docs, base view is open and not the page view
const baseViewOpen = computed(() => {
const routeNameSplit = String(route.value?.name).split('baseId-index-index')
@ -133,6 +134,10 @@ const enableEditMode = () => {
}
const updateProjectTitle = async () => {
if (tempTitle.value) {
tempTitle.value = tempTitle.value.trim()
}
if (!tempTitle.value) return
try {
@ -144,7 +149,7 @@ const updateProjectTitle = async () => {
$e('a:base:rename')
useTitle(`${base.value?.title}`)
refreshViewTabTitle?.()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
@ -389,6 +394,29 @@ const projectDelete = () => {
isProjectDeleteDialogVisible.value = true
$e('c:project:delete')
}
// Tracks if the table ID has been successfully copied to the clipboard
const isTableIdCopied = ref(false)
let tableIdCopiedTimeout: NodeJS.Timeout
const onTableIdCopy = async () => {
if (tableIdCopiedTimeout) {
clearTimeout(tableIdCopiedTimeout)
}
try {
await copy(contextMenuTarget.value.id)
isTableIdCopied.value = true
tableIdCopiedTimeout = setTimeout(() => {
isTableIdCopied.value = false
clearTimeout(tableIdCopiedTimeout)
}, 5000)
} catch (e: any) {
message.error(e.message)
}
}
</script>
<template>
@ -407,22 +435,8 @@ const projectDelete = () => {
'hover:bg-gray-200': !(activeProjectId === base.id && baseViewOpen),
}"
:data-testid="`nc-sidebar-base-title-${base.title}`"
class="nc-sidebar-node base-title-node h-7.25 flex-grow rounded-md group flex items-center w-full pr-1"
class="nc-sidebar-node base-title-node h-7.25 flex-grow rounded-md group flex items-center w-full pr-1 pl-1.5"
>
<NcButton
v-e="['c:base:expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand ml-0.75 !xs:visible"
@click="onProjectClick(base, true, true)"
>
<GeneralIcon
icon="triangleFill"
class="group-hover:visible cursor-pointer transform transition-transform duration-500 h-1.5 w-1.75 rotate-90 !xs:visible"
:class="{ '!rotate-180': base.isExpanded, '!visible': isOptionsOpen }"
/>
</NcButton>
<div class="flex items-center mr-1" @click="onProjectClick(base)">
<div class="flex items-center select-none w-6 h-full">
<a-spin v-if="base.isLoading" class="!ml-1.25 !flex !flex-row !items-center !my-0.5 w-8" :indicator="indicator" />
@ -445,7 +459,7 @@ const projectDelete = () => {
v-if="editMode"
ref="input"
v-model="tempTitle"
class="flex-grow leading-1 outline-0 ring-none capitalize !text-inherit !bg-transparent w-4/5"
class="flex-grow leading-1 outline-0 ring-none capitalize !text-inherit !bg-transparent flex-1 mr-4"
:class="{ 'text-black font-semibold': activeProjectId === base.id && baseViewOpen && !isMobileMode }"
@click.stop
@keyup.enter="updateProjectTitle"
@ -454,154 +468,176 @@ const projectDelete = () => {
/>
<NcTooltip
v-else
class="nc-sidebar-node-title capitalize text-ellipsis overflow-hidden select-none"
class="nc-sidebar-node-title capitalize text-ellipsis overflow-hidden select-none flex-1"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
:class="{ 'text-black font-semibold': activeProjectId === base.id && baseViewOpen }"
show-on-truncate-only
@click="onProjectClick(base)"
>
<template #title>{{ base.title }}</template>
<span @click="onProjectClick(base)">
<span>
{{ base.title }}
</span>
</NcTooltip>
<div :class="{ 'flex flex-grow h-full': !editMode }" @click="onProjectClick(base)"></div>
<NcDropdown v-if="!isSharedBase" v-model:visible="isOptionsOpen" :trigger="['click']">
<NcButton
v-e="['c:base:options']"
class="nc-sidebar-node-btn"
:class="{ '!text-black !opacity-100': isOptionsOpen }"
data-testid="nc-sidebar-context-menu"
type="text"
size="xxsmall"
@click.stop
>
<GeneralIcon icon="threeDotHorizontal" class="text-xl w-4.75" />
</NcButton>
<template #overlay>
<NcMenu
class="nc-scrollbar-md"
:style="{
maxHeight: '70vh',
overflow: 'overlay',
}"
:data-testid="`nc-sidebar-base-${base.title}-options`"
@click="isOptionsOpen = false"
<template v-if="!editMode">
<NcDropdown v-if="!isSharedBase" v-model:visible="isOptionsOpen" :trigger="['click']">
<NcButton
v-e="['c:base:options']"
class="nc-sidebar-node-btn"
:class="{ '!text-black !opacity-100 !inline-block': isOptionsOpen }"
data-testid="nc-sidebar-context-menu"
type="text"
size="xxsmall"
@click.stop
>
<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="rename" class="group-hover:text-black" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>
<GeneralIcon icon="threeDotHorizontal" class="text-xl w-4.75" />
</NcButton>
<template #overlay>
<NcMenu
class="nc-scrollbar-md"
:style="{
maxHeight: '70vh',
overflow: 'overlay',
}"
:data-testid="`nc-sidebar-base-${base.title}-options`"
@click="isOptionsOpen = false"
>
<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="rename" class="group-hover:text-black" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('baseDuplicate', { roles: [stringifyRolesObj(orgRoles), baseRole].join() })"
data-testid="nc-sidebar-base-duplicate"
@click="duplicateProject(base)"
>
<div v-e="['c:base:duplicate']" class="flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }}
</div>
</NcMenuItem>
<NcDivider v-if="['baseDuplicate', 'baseRename'].some((permission) => isUIAllowed(permission))" />
<!-- Copy Project Info -->
<NcMenuItem
v-if="!isEeUI"
key="copy"
data-testid="nc-sidebar-base-copy-base-info"
@click.stop="copyProjectInfo"
>
<div v-e="['c:base:copy-proj-info']" class="flex gap-2 items-center">
<GeneralIcon icon="copy" class="group-hover:text-black" />
{{ $t('activity.account.projInfo') }}
</div>
</NcMenuItem>
<!-- ERD View -->
<NcMenuItem
v-if="base?.sources?.[0]?.enabled"
key="erd"
data-testid="nc-sidebar-base-relations"
@click="openErdView(base?.sources?.[0])"
>
<div v-e="['c:base:erd']" class="flex gap-2 items-center">
<GeneralIcon icon="erd" />
{{ $t('title.relations') }}
</div>
</NcMenuItem>
<!-- Swagger: Rest APIs -->
<NcMenuItem
v-if="isUIAllowed('apiDocs')"
key="api"
data-testid="nc-sidebar-base-rest-apis"
@click.stop="
() => {
$e('c:base:api-docs')
openLink(`/api/v2/meta/bases/${base.id}/swagger`, appInfo.ncSiteUrl)
}
"
>
<div v-e="['c:base:api-docs']" class="flex gap-2 items-center">
<GeneralIcon icon="snippet" class="group-hover:text-black !max-w-3.9" />
{{ $t('activity.account.swagger') }}
</div>
</NcMenuItem>
</template>
<NcMenuItem
v-if="isUIAllowed('baseDuplicate', { roles: [stringifyRolesObj(orgRoles), baseRole].join() })"
data-testid="nc-sidebar-base-duplicate"
@click="duplicateProject(base)"
>
<div v-e="['c:base:duplicate']" class="flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }}
</div>
</NcMenuItem>
<template v-if="base?.sources?.[0]?.enabled && showBaseOption">
<NcDivider />
<DashboardTreeViewBaseOptions v-model:base="base" :source="base.sources[0]" />
</template>
<NcDivider v-if="['baseDuplicate', 'baseRename'].some((permission) => isUIAllowed(permission))" />
<NcDivider v-if="['baseMiscSettings', 'baseDelete'].some((permission) => isUIAllowed(permission))" />
<!-- Copy Project Info -->
<NcMenuItem
v-if="!isEeUI"
key="copy"
data-testid="nc-sidebar-base-copy-base-info"
@click.stop="copyProjectInfo"
v-if="isUIAllowed('baseMiscSettings')"
key="teamAndSettings"
data-testid="nc-sidebar-base-settings"
class="nc-sidebar-base-base-settings"
@click="toggleDialog(true, 'teamAndAuth', undefined, base.id)"
>
<div v-e="['c:base:copy-proj-info']" class="flex gap-2 items-center">
<GeneralIcon icon="copy" class="group-hover:text-black" />
{{ $t('activity.account.projInfo') }}
<div v-e="['c:base:settings']" class="flex gap-2 items-center">
<GeneralIcon icon="settings" class="group-hover:text-black" />
{{ $t('activity.settings') }}
</div>
</NcMenuItem>
<!-- ERD View -->
<NcMenuItem
v-if="base?.sources?.[0]?.enabled"
key="erd"
data-testid="nc-sidebar-base-relations"
@click="openErdView(base?.sources?.[0])"
v-if="isUIAllowed('baseDelete', { roles: [stringifyRolesObj(orgRoles), baseRole].join() })"
data-testid="nc-sidebar-base-delete"
class="!text-red-500 !hover:bg-red-50"
@click="projectDelete"
>
<div v-e="['c:base:erd']" class="flex gap-2 items-center">
<GeneralIcon icon="erd" />
{{ $t('title.relations') }}
<div class="flex gap-2 items-center">
<GeneralIcon icon="delete" class="w-4" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<!-- Swagger: Rest APIs -->
<NcMenuItem
v-if="isUIAllowed('apiDocs')"
key="api"
data-testid="nc-sidebar-base-rest-apis"
@click.stop="
() => {
$e('c:base:api-docs')
openLink(`/api/v2/meta/bases/${base.id}/swagger`, appInfo.ncSiteUrl)
}
"
>
<div v-e="['c:base:api-docs']" class="flex gap-2 items-center">
<GeneralIcon icon="snippet" class="group-hover:text-black !max-w-3.9" />
{{ $t('activity.account.swagger') }}
</div>
</NcMenuItem>
</template>
<template v-if="base?.sources?.[0]?.enabled && showBaseOption">
<NcDivider />
<DashboardTreeViewBaseOptions v-model:base="base" :source="base.sources[0]" />
</template>
<NcDivider v-if="['baseMiscSettings', 'baseDelete'].some((permission) => isUIAllowed(permission))" />
<NcButton
v-if="isUIAllowed('tableCreate', { roles: baseRole })"
v-e="['c:base:create-table']"
:disabled="!base?.sources?.[0]?.enabled"
class="nc-sidebar-node-btn"
size="xxsmall"
type="text"
data-testid="nc-sidebar-add-base-entity"
:class="{
'!text-black !inline-block !opacity-100': isAddNewProjectChildEntityLoading,
'!inline-block !opacity-100': isOptionsOpen,
}"
:loading="isAddNewProjectChildEntityLoading"
@click.stop="addNewProjectChildEntity"
>
<GeneralIcon icon="plus" class="text-xl leading-5" style="-webkit-text-stroke: 0.15px" />
</NcButton>
<NcMenuItem
v-if="isUIAllowed('baseMiscSettings')"
key="teamAndSettings"
data-testid="nc-sidebar-base-settings"
class="nc-sidebar-base-base-settings"
@click="toggleDialog(true, 'teamAndAuth', undefined, base.id)"
>
<div v-e="['c:base:settings']" class="flex gap-2 items-center">
<GeneralIcon icon="settings" class="group-hover:text-black" />
{{ $t('activity.settings') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('baseDelete', { roles: [stringifyRolesObj(orgRoles), baseRole].join() })"
data-testid="nc-sidebar-base-delete"
class="!text-red-500 !hover:bg-red-50"
@click="projectDelete"
>
<div class="flex gap-2 items-center">
<GeneralIcon icon="delete" class="w-4" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-if="isUIAllowed('tableCreate', { roles: baseRole })"
v-e="['c:base:create-table']"
:disabled="!base?.sources?.[0]?.enabled"
class="nc-sidebar-node-btn"
size="xxsmall"
type="text"
data-testid="nc-sidebar-add-base-entity"
:class="{ '!text-black !visible': isAddNewProjectChildEntityLoading, '!visible': isOptionsOpen }"
:loading="isAddNewProjectChildEntityLoading"
@click.stop="addNewProjectChildEntity"
>
<GeneralIcon icon="plus" class="text-xl leading-5" style="-webkit-text-stroke: 0.15px" />
</NcButton>
<NcButton
v-e="['c:base:expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand !xs:opacity-100"
:class="{
'!opacity-100': isOptionsOpen,
}"
@click="onProjectClick(base, true, true)"
>
<GeneralIcon
icon="chevronDown"
class="group-hover:visible cursor-pointer transform transition-transform duration-500 rotate-270"
:class="{ '!rotate-180': base.isExpanded }"
/>
</NcButton>
</template>
</div>
</div>
@ -634,25 +670,29 @@ const projectDelete = () => {
ghost
>
<template #expandIcon="{ isActive }">
<div
class="nc-sidebar-expand nc-sidebar-node-btn flex flex-row items-center -mt-2 xs:(mt-3 border-1 border-gray-200 px-2.25 py-0.5 rounded-md !mr-0.25)"
<NcButton
v-e="['c:external:base:expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand !xs:opacity-100"
:class="{ '!opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }"
>
<GeneralIcon
icon="triangleFill"
class="nc-sidebar-source-node-btns -mt-0.75 invisible xs:visible cursor-pointer transform transition-transform duration-500 h-1.5 w-1.5 text-gray-500 rotate-90"
icon="chevronDown"
class="flex-none cursor-pointer transform transition-transform duration-500 rotate-270"
:class="{ '!rotate-180': isActive }"
/>
</div>
</NcButton>
</template>
<a-collapse-panel :key="`collapse-${source.id}`">
<template #header>
<div class="nc-sidebar-node min-w-20 w-full flex flex-row group py-0.25">
<div class="nc-sidebar-node min-w-20 w-full h-full flex flex-row group py-0.25 pr-6.5 !mr-0">
<div
v-if="sourceIndex === 0"
class="source-context flex items-center gap-2 text-gray-800 nc-sidebar-node-title"
@contextmenu="setMenuContext('source', source)"
>
<GeneralBaseLogo class="min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
<GeneralBaseLogo class="flex-none min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
{{ $t('general.default') }}
</div>
<div
@ -660,22 +700,29 @@ const projectDelete = () => {
class="source-context flex flex-grow items-center gap-1.75 text-gray-800 min-w-1/20 max-w-full"
@contextmenu="setMenuContext('source', source)"
>
<GeneralBaseLogo class="min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
<div
:data-testid="`nc-sidebar-base-${source.alias}`"
class="nc-sidebar-node-title flex capitalize text-ellipsis overflow-hidden select-none"
<GeneralBaseLogo
class="flex-none min-w-4 !xs:(min-w-4.25 w-4.25 text-sm) !text-gray-600 !group-hover:text-gray-800"
/>
<NcTooltip
class="nc-sidebar-node-title capitalize text-ellipsis overflow-hidden select-none"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
:class="{
'text-black font-semibold': activeProjectId === base.id && baseViewOpen && !isMobileMode,
}"
show-on-truncate-only
>
{{ source.alias || '' }}
</div>
<a-tooltip class="xs:(hidden)">
<template #title> {{ source.alias || '' }}</template>
<span :data-testid="`nc-sidebar-base-${source.alias}`">
{{ source.alias || '' }}
</span>
</NcTooltip>
<NcTooltip class="xs:(hidden) flex items-center mr-1">
<template #title>{{ $t('objects.externalDb') }}</template>
<div>
<GeneralIcon icon="info" class="text-gray-400 -mt-0.5 hover:text-gray-700 mr-1" />
</div>
</a-tooltip>
<GeneralIcon icon="info" class="flex-none text-gray-400 hover:text-gray-700 mr-1" />
</NcTooltip>
</div>
<div class="flex flex-row items-center gap-x-0.25 w-12.25">
<div class="flex flex-row items-center gap-x-0.25">
<NcDropdown
:visible="isBasesOptionsOpen[source!.id!]"
:trigger="['click']"
@ -684,7 +731,7 @@ const projectDelete = () => {
<NcButton
v-e="['c:source:options']"
class="nc-sidebar-node-btn"
:class="{ '!text-black !opacity-100': isBasesOptionsOpen[source!.id!] }"
:class="{ '!text-black !opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }"
type="text"
size="xxsmall"
@click.stop="isBasesOptionsOpen[source!.id!] = !isBasesOptionsOpen[source!.id!]"
@ -719,6 +766,7 @@ const projectDelete = () => {
type="text"
size="xxsmall"
class="nc-sidebar-node-btn"
:class="{ '!opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }"
@click.stop="openTableCreateDialog(sourceIndex)"
>
<GeneralIcon icon="plus" class="text-xl leading-5" style="-webkit-text-stroke: 0.15px" />
@ -743,35 +791,63 @@ const projectDelete = () => {
</div>
</div>
<template v-if="!isSharedBase" #overlay>
<NcMenu class="!py-0 rounded text-sm">
<NcMenu
class="!py-0 rounded text-sm"
:class="{
'!min-w-62.5': contextMenuTarget.type === 'table',
}"
>
<template v-if="contextMenuTarget.type === 'base' && base.type === 'database'"></template>
<template v-else-if="contextMenuTarget.type === 'source'"></template>
<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="rename" class="text-gray-700" />
{{ $t('general.rename') }}
<NcTooltip>
<template #title> {{ $t('labels.clickToCopyTableID') }} </template>
<div
class="flex items-center justify-between p-2 mx-1.5 rounded-md cursor-pointer hover:bg-gray-100 group"
@click.stop="onTableIdCopy"
>
<div class="flex text-xs font-bold text-gray-500 ml-1">
{{
$t('labels.tableIdColon', {
tableId: contextMenuTarget.value?.id,
})
}}
</div>
<NcButton class="!group-hover:bg-gray-100" size="xsmall" type="secondary">
<GeneralIcon v-if="isTableIdCopied" class="max-h-4 min-w-4" icon="check" />
<GeneralIcon v-else class="max-h-4 min-w-4" else icon="copy" />
</NcButton>
</div>
</NcMenuItem>
</NcTooltip>
<NcMenuItem
v-if="isUIAllowed('tableDuplicate') && (contextMenuBase?.is_meta || contextMenuBase?.is_local)"
@click="duplicateTable(contextMenuTarget.value)"
>
<div v-e="['c:table:duplicate']" class="nc-base-option-item flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem v-if="isUIAllowed('tableDelete')" class="!hover:bg-red-50" @click="tableDelete">
<div class="nc-base-option-item flex gap-2 items-center text-red-600">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
<template v-if="isUIAllowed('tableRename') || isUIAllowed('tableDelete')">
<NcDivider />
<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="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.table') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('tableDuplicate') && (contextMenuBase?.is_meta || contextMenuBase?.is_local)"
@click="duplicateTable(contextMenuTarget.value)"
>
<div v-e="['c:table:duplicate']" class="nc-base-option-item flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }} {{ $t('objects.table') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem v-if="isUIAllowed('table-delete')" class="!hover:bg-red-50" @click="tableDelete">
<div class="nc-base-option-item flex gap-2 items-center text-red-600">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table') }}
</div>
</NcMenuItem>
</template>
</template>
</NcMenu>
</template>
@ -793,7 +869,11 @@ const projectDelete = () => {
<style lang="scss" scoped>
:deep(.ant-collapse-header) {
@apply !mx-0 !pl-8.75 !xs:(pl-8) !pr-0.5 !py-0.5 hover:bg-gray-200 xs:(hover:bg-gray-50 ) !rounded-md;
@apply !mx-0 !pl-8.75 h-7.1 !xs:(pl-7 h-[3rem]) !pr-0.5 !py-0 hover:bg-gray-200 xs:(hover:bg-gray-50) !rounded-md;
.ant-collapse-arrow {
@apply !right-1 !xs:(flex-none border-1 border-gray-200 w-6.5 h-6.5 mr-1);
}
}
:deep(.ant-collapse-item) {
@ -804,7 +884,13 @@ const projectDelete = () => {
@apply !px-0 !pb-0 !pt-0.25;
}
:deep(.ant-collapse-header:hover .nc-sidebar-source-node-btns) {
@apply visible;
:deep(.ant-collapse-header:hover) {
.nc-sidebar-node-btn {
@apply !opacity-100 !inline-block;
&:not(.nc-sidebar-expand) {
@apply !xs:hidden;
}
}
}
</style>

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

@ -137,8 +137,8 @@ const availableTables = computed(() => {
v-if="availableTables.length === 0"
class="py-0.5 text-gray-500"
:class="{
'ml-13.55': sourceIndex === 0,
'ml-19.25': sourceIndex !== 0,
'ml-8.5': sourceIndex === 0,
'ml-14.5 xs:(ml-15.25)': sourceIndex !== 0,
}"
>
{{ $t('general.empty') }}

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

@ -4,7 +4,7 @@ import { toRef } from '@vue/reactivity'
import { message } from 'ant-design-vue'
import { storeToRefs } from 'pinia'
import { ProjectRoleInj, TreeViewInj, useMagicKeys, useNuxtApp, useRoles, useTabs } from '#imports'
import { ProjectRoleInj, TreeViewInj, useCopy, useMagicKeys, useNuxtApp, useRoles, useTabs } from '#imports'
import type { SidebarTableNode } from '~/lib'
const props = withDefaults(
@ -41,10 +41,12 @@ useTableNew({
const { meta: metaKey, control } = useMagicKeys()
const { copy } = useCopy()
const baseRole = inject(ProjectRoleInj)
provide(SidebarTableInj, table)
const { setMenuContext, openRenameTableDialog, duplicateTable } = inject(TreeViewInj)!
const { setMenuContext, openRenameTableDialog: _openRenameTableDialog, duplicateTable: _duplicateTable } = inject(TreeViewInj)!
const { loadViews: _loadViews } = useViewsStore()
const { activeView, activeViewTitleOrId, viewsByTable } = storeToRefs(useViewsStore())
@ -58,6 +60,8 @@ const openedTableId = computed(() => route.params.viewId)
const isTableDeleteDialogVisible = ref(false)
const isOptionsOpen = ref(false)
const setIcon = async (icon: string, table: TableType) => {
try {
table.meta = {
@ -90,6 +94,9 @@ const canUserEditEmote = computed(() => {
const isExpanded = ref(false)
const isLoading = ref(false)
// Tracks if the table ID has been successfully copied to the clipboard
const isTableIdCopied = ref(false)
const onExpand = async () => {
if (isExpanded.value) {
isExpanded.value = false
@ -127,6 +134,25 @@ const onOpenTable = async () => {
isExpanded.value = true
}
}
let tableIdCopiedTimeout: NodeJS.Timeout
const onTableIdCopy = async () => {
if (tableIdCopiedTimeout) {
clearTimeout(tableIdCopiedTimeout)
}
try {
await copy(table.value!.id!)
isTableIdCopied.value = true
tableIdCopiedTimeout = setTimeout(() => {
isTableIdCopied.value = false
clearTimeout(tableIdCopiedTimeout)
}, 5000)
} catch (e: any) {
message.error(e.message)
}
}
watch(
() => activeView.value?.id,
@ -166,6 +192,21 @@ watch(openedTableId, () => {
}, 10000)
}
})
const duplicateTable = (table: SidebarTableNode) => {
isOptionsOpen.value = false
_duplicateTable(table)
}
const openRenameTableDialog = (table: SidebarTableNode, sourceId: string) => {
isOptionsOpen.value = false
_openRenameTableDialog(table, !!sourceId)
}
const deleteTable = () => {
isOptionsOpen.value = false
isTableDeleteDialogVisible.value = true
}
</script>
<template>
@ -174,16 +215,16 @@ watch(openedTableId, () => {
:data-order="table.order"
:data-id="table.id"
:data-table-id="table.id"
:class="[`nc-base-tree-tbl nc-base-tree-tbl-${table.title}`]"
:class="[`nc-base-tree-tbl nc-base-tree-tbl-${table.title?.replaceAll(' ', '')}`]"
:data-active="openedTableId === table.id"
>
<div
v-e="['a:table:open']"
class="table-context flex items-center gap-1 h-full nc-tree-item-inner nc-sidebar-node pl-11 pr-0.75 mb-0.25 rounded-md h-7.1 w-full group cursor-pointer hover:bg-gray-200"
class="table-context flex items-center gap-1 h-full nc-tree-item-inner nc-sidebar-node pr-0.75 mb-0.25 rounded-md h-7.1 w-full group cursor-pointer hover:bg-gray-200"
:class="{
'hover:bg-gray-200': openedTableId !== table.id,
'pl-12 xs:(pl-14)': sourceIndex !== 0,
'pl-6.5': sourceIndex === 0,
'pl-13.5': sourceIndex !== 0,
'pl-7.5 xs:(pl-6)': sourceIndex === 0,
'!bg-primary-selected': isTableOpened,
}"
:data-testid="`nc-tbl-side-node-${table.title}`"
@ -191,30 +232,10 @@ watch(openedTableId, () => {
@click="onOpenTable"
>
<div class="flex flex-row h-full items-center">
<NcButton
v-e="['c:table:toggle-expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand"
@click.stop="onExpand"
>
<GeneralLoader
v-if="table.isViewsLoading"
class="flex w-4 h-4 !text-gray-600 !mt-0.75"
:class="{
'!visible': !isExpanded,
}"
/>
<GeneralIcon
v-else
icon="triangleFill"
class="nc-sidebar-source-node-btns group-hover:visible invisible cursor-pointer transform transition-transform duration-500 h-1.5 w-1.5 !text-gray-600 rotate-90"
:class="{ '!rotate-180': isExpanded }"
/>
</NcButton>
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<GeneralLoader v-if="table.isViewsLoading" class="flex items-center w-6 h-full !text-gray-600" />
<div
v-else
v-e="['c:table:emoji-picker']"
class="flex items-center nc-table-icon"
:class="{
@ -238,10 +259,11 @@ watch(openedTableId, () => {
<component
:is="iconMap.table"
v-if="table.type === 'table'"
class="flex w-5 !text-gray-500 text-sm"
class="w-4 text-sm"
:class="{
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: baseRole }),
'!text-black': openedTableId === table.id,
'!group-hover:text-gray-700': isUIAllowed('tableSort', { roles: baseRole }),
'!text-gray-700': openedTableId === table.id,
'!text-gray-600/75': openedTableId !== table.id,
}"
/>
@ -260,7 +282,7 @@ watch(openedTableId, () => {
</div>
</div>
<NcTooltip
class="nc-tbl-title nc-sidebar-node-title text-ellipsis w-full overflow-hidden select-none"
class="nc-tbl-title nc-sidebar-node-title text-ellipsis overflow-hidden select-none !flex-1"
show-on-truncate-only
>
<template #title>{{ table.title }}</template>
@ -274,64 +296,110 @@ watch(openedTableId, () => {
{{ table.title }}
</span>
</NcTooltip>
<div class="flex flex-grow h-full"></div>
<div class="flex flex-row items-center">
<div
v-if="
!isSharedBase && (isUIAllowed('tableRename', { roles: baseRole }) || isUIAllowed('tableDelete', { roles: baseRole }))
"
<NcDropdown v-model:visible="isOptionsOpen" :trigger="['click']" @click.stop>
<NcButton
v-e="['c:table:option']"
class="nc-sidebar-node-btn nc-tbl-context-menu text-gray-600"
:class="{
'!opacity-100 !inline-block': isOptionsOpen,
}"
data-testid="nc-sidebar-table-context-menu"
type="text"
size="xxsmall"
@click.stop
>
<NcDropdown :trigger="['click']" class="nc-sidebar-node-btn" @click.stop>
<MdiDotsHorizontal
data-testid="nc-sidebar-table-context-menu"
class="min-w-5.75 min-h-5.75 mt-0.2 mr-0.25 px-0.5 !text-gray-600 transition-opacity opacity-0 group-hover:opacity-100 nc-tbl-context-menu outline-0 rounded-md hover:(bg-gray-500 bg-opacity-15 !text-black)"
/>
<template #overlay>
<NcMenu>
<NcMenuItem
v-if="isUIAllowed('tableRename', { roles: baseRole })"
:data-testid="`sidebar-table-rename-${table.title}`"
@click="openRenameTableDialog(table, base.sources[sourceIndex].id)"
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="
isUIAllowed('tableDuplicate') &&
base.sources?.[sourceIndex] &&
(base.sources[sourceIndex].is_meta || base.sources[sourceIndex].is_local)
"
:data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)"
>
<div v-e="['c:table:duplicate']" class="flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('tableDelete', { roles: baseRole })"
:data-testid="`sidebar-table-delete-${table.title}`"
class="!text-red-500 !hover:bg-red-50"
@click="isTableDeleteDialogVisible = true"
>
<div v-e="['c:table:delete']" class="flex gap-2 items-center">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</NcMenu>
<MdiDotsHorizontal class="!text-gray-600" />
</NcButton>
<template #overlay>
<NcMenu class="!min-w-62.5" :data-testid="`sidebar-table-context-menu-list-${table.title}`">
<NcTooltip>
<template #title> {{ $t('labels.clickToCopyTableID') }} </template>
<div
class="flex items-center justify-between p-2 mx-1.5 rounded-md cursor-pointer hover:bg-gray-100 group"
@click.stop="onTableIdCopy"
>
<div class="flex text-xs font-bold text-gray-500 ml-1">
{{
$t('labels.tableIdColon', {
tableId: table?.id,
})
}}
</div>
<NcButton class="!group-hover:bg-gray-100" size="xsmall" type="secondary">
<GeneralIcon v-if="isTableIdCopied" class="max-h-4 min-w-4" icon="check" />
<GeneralIcon v-else class="max-h-4 min-w-4" else icon="copy" />
</NcButton>
</div>
</NcTooltip>
<template
v-if="
!isSharedBase &&
(isUIAllowed('tableRename', { roles: baseRole }) || isUIAllowed('tableDelete', { roles: baseRole }))
"
>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableRename', { roles: baseRole })"
:data-testid="`sidebar-table-rename-${table.title}`"
@click="openRenameTableDialog(table, base.sources[sourceIndex].id)"
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.table') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="
isUIAllowed('tableDuplicate') &&
base.sources?.[sourceIndex] &&
(base.sources[sourceIndex].is_meta || base.sources[sourceIndex].is_local)
"
:data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)"
>
<div v-e="['c:table:duplicate']" class="flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }} {{ $t('objects.table') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableDelete', { roles: baseRole })"
:data-testid="`sidebar-table-delete-${table.title}`"
class="!text-red-500 !hover:bg-red-50"
@click="deleteTable"
>
<div v-e="['c:table:delete']" class="flex gap-2 items-center">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table') }}
</div>
</NcMenuItem>
</template>
</NcDropdown>
</div>
</div>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-e="['c:table:toggle-expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand"
:class="{
'!opacity-100 !visible': isOptionsOpen,
}"
@click.stop="onExpand"
>
<GeneralIcon
icon="chevronDown"
class="nc-sidebar-source-node-btns cursor-pointer transform transition-transform duration-500 !text-gray-600 rotate-270"
:class="{ '!rotate-180': isExpanded }"
/>
</NcButton>
</div>
<DlgTableDelete
v-if="table.id && base?.id"

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

@ -413,8 +413,8 @@ function onOpenModal({
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,
'!pl-13.3 !xs:(pl-13.5)': isDefaultSource,
'!pl-18.6 !xs:(pl-20)': !isDefaultSource,
}"
>
<div

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

@ -158,6 +158,10 @@ async function onRename() {
isDropdownOpen.value = false
if (!isEditing.value) return
if (_title.value) {
_title.value = _title.value.trim()
}
const isValid = props.onValidate({ ...vModel.value, title: _title.value! })
if (isValid !== true) {
@ -217,8 +221,8 @@ watch(isDropdownOpen, async () => {
<a-menu-item
class="nc-sidebar-node !min-h-7 !max-h-7 !mb-0.25 select-none group text-gray-700 !flex !items-center !mt-0 hover:(!bg-gray-200 !text-gray-900) cursor-pointer"
:class="{
'!pl-18 !xs:(pl-19.75)': isDefaultBase,
'!pl-23.5 !xs:(pl-27)': !isDefaultBase,
'!pl-13.5 !xs:(pl-12)': isDefaultBase,
'!pl-19 ': !isDefaultBase,
}"
:data-testid="`view-sidebar-view-${vModel.alias || vModel.title}`"
@dblclick.stop="onDblClick"
@ -248,7 +252,7 @@ watch(isDropdownOpen, async () => {
v-if="isEditing"
:ref="focusInput"
v-model:value="_title"
class="!bg-transparent !border-0 !ring-0 !outline-transparent !border-transparent !pl-0"
class="!bg-transparent !border-0 !ring-0 !outline-transparent !border-transparent !pl-0 !flex-1 mr-4"
:class="{
'font-medium': activeView?.id === vModel.id,
}"
@ -267,7 +271,6 @@ watch(isDropdownOpen, async () => {
{{ vModel.alias || vModel.title }}
</div>
</NcTooltip>
<div class="flex-1" />
<template v-if="!isEditing && !isLocked">
<NcDropdown v-model:visible="isDropdownOpen" overlay-class-name="!rounded-lg">
@ -275,11 +278,12 @@ watch(isDropdownOpen, async () => {
v-e="['c:view:option']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn invisible !group-hover:visible nc-sidebar-view-node-context-btn"
class="nc-sidebar-node-btn invisible !group-hover:(visible opacity-100) nc-sidebar-view-node-context-btn"
:class="{
'!visible': isDropdownOpen,
'!visible !opacity-100': isDropdownOpen,
}"
@click.stop="isDropdownOpen = !isDropdownOpen"
@dblclick.stop
>
<GeneralIcon icon="threeDotHorizontal" class="text-xl w-4.75" />
</NcButton>

12
packages/nc-gui/components/dashboard/View.vue

@ -143,8 +143,8 @@ function onResize(widthPercent: any) {
sideBarSize.value.old = ((16 * fontSize) / viewportWidth.value) * 100
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
} else if (widthRem > 23.5) {
sideBarSize.value.old = ((23.5 * fontSize) / viewportWidth.value) * 100
} else if (widthRem > 35) {
sideBarSize.value.old = ((35 * fontSize) / viewportWidth.value) * 100
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
@ -155,7 +155,7 @@ function onResize(widthPercent: any) {
}
const normalizedWidth = computed(() => {
const maxSize = remToPx(23.5)
const maxSize = remToPx(35)
const minSize = remToPx(16)
if (sidebarWidth.value > maxSize) {
return maxSize
@ -178,15 +178,15 @@ const normalizedWidth = computed(() => {
<Pane
min-size="15%"
:size="mobileNormalizedSidebarSize"
max-size="40%"
class="nc-sidebar-splitpane !sm:max-w-94 relative !overflow-visible flex"
max-size="60%"
class="nc-sidebar-splitpane !sm:max-w-140 relative !overflow-visible flex"
:style="{
width: `${mobileNormalizedSidebarSize}%`,
}"
>
<div
ref="wrapperRef"
class="nc-sidebar-wrapper relative flex flex-col h-full justify-center !sm:(max-w-94) absolute overflow-visible"
class="nc-sidebar-wrapper relative flex flex-col h-full justify-center !sm:(max-w-140) absolute overflow-visible"
:class="{
'mobile': isMobileMode,
'minimized-height': !isLeftSidebarOpen,

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

@ -290,11 +290,11 @@ const isEditBaseModalOpen = computed({
<template>
<div class="flex flex-row w-full h-full nc-data-sources-view">
<div class="flex flex-col w-full overflow-auto">
<div class="flex flex-row w-full justify-end mt-6 mb-5">
<div class="flex flex-row w-full justify-end mt-6.5 mb-2">
<NcButton
v-if="dataSourcesAwakened"
size="large"
class="z-10 !rounded-lg !px-2 mr-2.5"
class="z-10 !px-2"
type="primary"
@click="vState = DataSourcesSubTab.New"
>

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

@ -86,8 +86,7 @@ const customFormState = ref<ProjectCreateForm>({
const clientTypes = computed(() => {
return _clientTypes.filter((type) => {
// return appInfo.value?.ee || type.value !== ClientType.SNOWFLAKE
return type.value !== ClientType.SNOWFLAKE
return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS].includes(type.value)
})
})

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

@ -8,8 +8,8 @@ import {
ClientType,
ProjectIdInj,
SSLUsage,
clientTypes as _clientTypes,
baseTitleValidator,
clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
@ -57,6 +57,12 @@ const { t } = useI18n()
const editingSource = ref(false)
const clientTypes = computed(() => {
return _clientTypes.filter((type) => {
return ![ClientType.SNOWFLAKE, ClientType.DATABRICKS].includes(type.value)
})
})
const formState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
@ -437,6 +443,40 @@ onMounted(async () => {
</a-form-item>
</template>
<template v-else-if="formState.dataSource.client === ClientType.DATABRICKS">
<a-form-item label="Token" v-bind="validateInfos['dataSource.connection.token']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).token"
class="nc-extdb-host-token"
/>
</a-form-item>
<a-form-item label="Host" v-bind="validateInfos['dataSource.connection.host']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).host"
class="nc-extdb-host-address"
/>
</a-form-item>
<a-form-item label="Path" v-bind="validateInfos['dataSource.connection.path']">
<a-input v-model:value="(formState.dataSource.connection as DatabricksConnection).path" class="nc-extdb-host-path" />
</a-form-item>
<a-form-item label="Database" v-bind="validateInfos['dataSource.connection.database']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).database"
class="nc-extdb-host-database"
/>
</a-form-item>
<a-form-item label="Schema" v-bind="validateInfos['dataSource.connection.schema']">
<a-input
v-model:value="(formState.dataSource.connection as DatabricksConnection).schema"
class="nc-extdb-host-schema"
/>
</a-form-item>
</template>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">

208
packages/nc-gui/components/project/ShareBaseDlg.vue → packages/nc-gui/components/dlg/InviteDlg.vue

@ -1,30 +1,43 @@
<script setup lang="ts">
import type { RoleLabels } from 'nocodb-sdk'
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
<script lang="ts" setup>
import { ProjectRoles, type RoleLabels, WorkspaceUserRoles } from 'nocodb-sdk'
import type { User } from '#imports'
import { extractEmail } from '~/helpers/parsers/parserHelpers'
const props = defineProps<{
modelValue: boolean
type?: 'base' | 'workspace' | 'organization'
baseId?: string
emails?: string[]
workspaceId?: string
}>()
const emit = defineEmits(['update:modelValue'])
const dialogShow = useVModel(props, 'modelValue', emit)
const inviteData = reactive({
email: '',
roles: ProjectRoles.NO_ACCESS,
})
const { baseRoles } = useRoles()
const { baseRoles, workspaceRoles } = useRoles()
const basesStore = useBases()
const { activeProjectId } = storeToRefs(basesStore)
const workspaceStore = useWorkspace()
const { createProjectUser } = basesStore
const { inviteCollaborator: inviteWsCollaborator } = workspaceStore
const dialogShow = useVModel(props, 'modelValue', emit)
const orderedRoles = computed(() => {
return props.type === 'base' ? ProjectRoles : WorkspaceUserRoles
})
const userRoles = computed(() => {
return props.type === 'base' ? baseRoles.value : workspaceRoles.value
})
const inviteData = reactive({
email: '',
roles: orderedRoles.value.NO_ACCESS,
})
const divRef = ref<HTMLDivElement>()
const focusRef = ref<HTMLInputElement>()
@ -35,23 +48,44 @@ const emailValidation = reactive({
message: '',
})
const allowedRoles = ref<ProjectRoles[]>([])
const singleEmailValue = ref('')
onMounted(async () => {
try {
const currentRoleIndex = OrderedProjectRoles.findIndex(
(role) => baseRoles.value && Object.keys(baseRoles.value).includes(role),
)
if (currentRoleIndex !== -1) {
allowedRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1).filter((r) => r)
const emailBadges = ref<Array<string>>([])
const allowedRoles = ref<[]>([])
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
watch(dialogShow, async (newVal) => {
if (newVal) {
try {
// todo: enable after discussing with anbu
// const currentRoleIndex = Object.values(orderedRoles.value).findIndex(
// (role) => userRoles.value && Object.keys(userRoles.value).includes(role),
// )
// if (currentRoleIndex !== -1) {
allowedRoles.value = Object.values(orderedRoles.value) // .slice(currentRoleIndex + 1)
// }
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
if (props.emails) {
emailBadges.value = props.emails
}
setTimeout(() => {
focusOnDiv()
}, 100)
} else {
emailBadges.value = []
inviteData.email = ''
singleEmailValue.value = ''
}
})
const singleEmailValue = ref('')
const emailBadges = ref<Array<string>>([])
const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array
@ -84,7 +118,7 @@ const emailInputValidation = (input: string, isBulkEmailCopyPaste: boolean = fal
return true
}
const isInvitButtonDiabled = computed(() => {
const isInviteButtonDisabled = computed(() => {
if (!emailBadges.value.length && !singleEmailValue.value.length) {
return true
}
@ -95,7 +129,7 @@ const isInvitButtonDiabled = computed(() => {
watch(inviteData, (newVal) => {
// when user only want to enter a single email
// we dont convert that as badge
// we don't convert that as badge
const isSingleEmailValid = validateEmail(newVal.email)
if (isSingleEmailValid && !emailBadges.value.length) {
@ -105,7 +139,7 @@ watch(inviteData, (newVal) => {
}
singleEmailValue.value = ''
// when user enters multiple emails comma sepearted or space sepearted
// when user enters multiple emails comma separated or space separated
const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length) {
const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim()
@ -140,12 +174,6 @@ const handleEnter = () => {
emailValidation.isError = false
emailValidation.message = ''
}
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
@ -197,7 +225,9 @@ const onPaste = (e: ClipboardEvent) => {
inviteData.email = ''
}
const inviteProjectCollaborator = async () => {
const workSpaces = ref<NcWorkspace[]>([])
const inviteCollaborator = async () => {
try {
const payloadData = singleEmailValue.value || emailBadges.value.join(',')
if (!payloadData.includes(',')) {
@ -207,10 +237,19 @@ const inviteProjectCollaborator = async () => {
emailValidation.message = 'invalid email'
}
}
await createProjectUser(activeProjectId.value!, {
email: payloadData,
roles: inviteData.roles,
} as unknown as User)
if (props.type === 'base' && props.baseId) {
await createProjectUser(props.baseId!, {
email: payloadData,
roles: inviteData.roles,
} as unknown as User)
} else if (props.type === 'workspace' && props.workspaceId) {
await inviteWsCollaborator(payloadData, inviteData.roles, props.workspaceId)
} else if (props.type === 'organization') {
// TODO: Add support for Bulk Workspace Invite
for (const workspace of workSpaces.value) {
await inviteWsCollaborator(payloadData, inviteData.roles, workspace.id)
}
}
message.success('Invitation sent successfully')
inviteData.email = ''
@ -223,40 +262,70 @@ const inviteProjectCollaborator = async () => {
}
}
const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role as ProjectRoles)
const organizationStore = useOrganization()
const { listWorkspaces } = organizationStore
const { workspaces } = storeToRefs(organizationStore)
const workSpaceSelectList = computed(() => {
return workspaces.value.filter((w) => !workSpaces.value.find((ws) => ws.id === w.id))
})
const addToList = (workspaceId: string) => {
workSpaces.value.push(workspaces.value.find((w) => w.id === workspaceId)!)
}
const removeWorkspace = (workspaceId: string) => {
workSpaces.value = workSpaces.value.filter((w) => w.id !== workspaceId)
}
onMounted(async () => {
if (props.type === 'organization') {
await listWorkspaces()
}
})
const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role as ProjectRoles | WorkspaceUserRoles)
</script>
<template>
<NcModal
v-model:visible="dialogShow"
:show-separator="false"
:header="$t('activity.createTable')"
:show-separator="false"
size="medium"
class="nc-invite-dlg"
@keydown.esc="dialogShow = false"
>
<template #header>
<div class="flex flex-row items-center gap-x-2">
{{ $t('activity.addMember') }}
{{
type === 'organization'
? $t('labels.addMembersToOrganization')
: type === 'base'
? $t('activity.addMember')
: $t('activity.inviteToWorkspace')
}}
</div>
</template>
<div class="flex items-center justify-between gap-3 mt-2">
<div class="flex w-full flex-col">
<div class="flex w-full gap-4 flex-col">
<div class="flex justify-between gap-3 w-full">
<div
ref="divRef"
class="flex items-center border-1 gap-1 w-full overflow-x-auto nc-scrollbar-x-md items-center h-10 rounded-lg !min-w-96"
tabindex="0"
:class="{
'border-primary/100': isDivFocused,
'p-1': emailBadges?.length > 1,
}"
@click="focusOnDiv"
class="flex items-center border-1 gap-1 w-full overflow-x-scroll nc-scrollbar-x-md items-center h-10 rounded-lg !min-w-96"
tabindex="0"
@blur="isDivFocused = false"
@click="focusOnDiv"
>
<span
v-for="(email, index) in emailBadges"
:key="email"
class="border-1 text-gray-800 bg-gray-100 rounded-md flex items-center px-2 py-1"
class="border-1 text-gray-800 first:ml-1 bg-gray-100 rounded-md flex items-center px-2 py-1"
>
{{ email }}
<component
@ -272,38 +341,65 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
:placeholder="$t('activity.enterEmail')"
class="w-full min-w-36 outline-none px-2"
data-testid="email-input"
@keyup.enter="handleEnter"
@blur="isDivFocused = false"
@keyup.enter="handleEnter"
@paste.prevent="onPaste"
/>
</div>
<RolesSelector
size="lg"
class="nc-invite-role-selector"
:description="false"
:on-role-change="onRoleChange"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="onRoleChange"
:description="false"
class="!min-w-[152px] nc-invite-role-selector"
size="lg"
/>
</div>
<span v-if="emailValidation.isError && emailValidation.message" class="ml-2 text-red-500 text-[10px] mt-1.5">{{
emailValidation.message
}}</span>
<template v-if="type === 'organization'">
<NcSelect :placeholder="$t('labels.selectWorkspace')" size="middle" @change="addToList">
<a-select-option v-for="workspace in workSpaceSelectList" :key="workspace.id" :value="workspace.id">
{{ workspace.title }}
</a-select-option>
</NcSelect>
<div class="flex flex-wrap gap-2">
<NcBadge v-for="workspace in workSpaces" :key="workspace.id">
<div class="px-2 flex gap-2 items-center py-1">
<GeneralWorkspaceIcon :workspace="workspace" hide-label size="small" />
<span class="text-gray-600">
{{ workspace.title }}
</span>
<component :is="iconMap.close" class="w-3 h-3" @click="removeWorkspace(workspace.id)" />
</div>
</NcBadge>
</div>
</template>
</div>
</div>
<div class="flex mt-8 justify-end">
<div class="flex gap-2">
<NcButton type="secondary" @click="dialogShow = false"> {{ $t('labels.cancel') }} </NcButton>
<NcButton
type="primary"
:disabled="isInviteButtonDisabled || emailValidation.isError"
size="medium"
:disabled="isInvitButtonDiabled || emailValidation.isError"
@click="inviteProjectCollaborator"
type="primary"
class="nc-invite-btn"
@click="inviteCollaborator"
>
{{ $t('activity.inviteToBase') }}
{{ type === 'base' ? $t('activity.inviteToBase') : $t('activity.inviteToWorkspace') }}
</NcButton>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped>
:deep(.nc-invite-role-selector .nc-role-badge) {
@apply w-full;
}
</style>

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

@ -146,7 +146,7 @@ const importMeta = computed(() => {
const dialogShow = useVModel(rest, 'modelValue', emit)
// watch dialogShow to init or terminate worker
if (isWorkerSupport) {
if (isWorkerSupport && process.env.NODE_ENV === 'production') {
watch(
dialogShow,
async (val) => {

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

@ -139,7 +139,7 @@ onMounted(() => {
<NcModal v-model:visible="dialogShow" :header="$t('activity.createTable')" size="small" @keydown.esc="dialogShow = false">
<template #header>
<div class="flex flex-row items-center gap-x-2">
<GeneralIcon icon="table" />
<GeneralIcon icon="table" class="!text-gray-600/75" />
{{ $t('activity.createTable') }}
</div>
</template>

5
packages/nc-gui/components/dlg/TableDelete.vue

@ -79,8 +79,9 @@ const onDelete = async () => {
$e('a:table:delete')
if (oldActiveTableId === toBeDeletedTable.id) {
const sourceTables = tables.value.filter((t) => t.source_id === toBeDeletedTable.source_id)
// Navigate to base if no tables left or open first table
if (tables.value.length === 0) {
if (sourceTables.length === 0) {
await navigateTo(
baseUrl({
id: props.baseId,
@ -88,7 +89,7 @@ const onDelete = async () => {
}),
)
} else {
await openTable(tables.value[0])
await openTable(sourceTables[0])
}
}

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

@ -1,7 +1,6 @@
<script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import type { ComponentPublicInstance } from '@vue/runtime-core'
import { useTitle } from '@vueuse/core'
import {
Form,
computed,
@ -88,11 +87,10 @@ const validators = computed(() => {
{
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
if (/^\s+|\s+$/.test(value)) {
return reject(new Error('Leading or trailing whitespace not allowed in table name'))
}
if (
!(tables?.value || []).every((t) => t.id === tableMeta.id || t.title.toLowerCase() !== (value || '').toLowerCase())
!(tables?.value || []).every(
(t) => t.id === tableMeta.id || t.title.toLowerCase() !== (value?.trim() || '').toLowerCase(),
)
) {
return reject(new Error('Duplicate table alias'))
}
@ -124,6 +122,11 @@ watchEffect(
const renameTable = async (undo = false, disableTitleDiffCheck?: boolean | undefined) => {
if (!tableMeta) return
if (formState.title) {
formState.title = formState.title.trim()
}
if (formState.title === tableMeta.title && !disableTitleDiffCheck) return
loading.value = true
@ -180,8 +183,6 @@ const renameTable = async (undo = false, disableTitleDiffCheck?: boolean | undef
$e('a:table:rename')
useTitle(`${base.value?.title}: ${newMeta?.title}`)
dialogShow.value = false
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -219,7 +220,7 @@ const renameTable = async (undo = false, disableTitleDiffCheck?: boolean | undef
<NcButton
key="submit"
type="primary"
:disabled="validateInfos.title.validateStatus === 'error' || formState.title === tableMeta.title"
:disabled="validateInfos.title.validateStatus === 'error' || formState.title?.trim() === tableMeta.title"
label="Rename Table"
loading-label="Renaming Table"
:loading="loading"

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

@ -162,6 +162,10 @@ async function onSubmit() {
console.error(e)
}
if (form.title) {
form.title = form.title.trim()
}
if (isValid && form.type) {
if (!tableId.value) return

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

@ -10,7 +10,7 @@ import { iconMap } from '#imports'
>
<div class="flex flex-col">
<div class="flex items-center gap-1.5 p-2">
<component :is="iconMap.table" />
<component :is="iconMap.table" class="!text-gray-600/75" />
<div>{{ $t('objects.table') }}</div>
</div>

110
packages/nc-gui/components/extensions/Details.vue

@ -0,0 +1,110 @@
<script lang="ts" setup>
import { useVModel } from '#imports'
interface Prop {
modelValue: boolean
extensionId: string
from: 'market' | 'extension'
}
const props = defineProps<Prop>()
const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionIcon, isMarketVisible } = useExtensions()
const onBack = () => {
vModel.value = false
isMarketVisible.value = true
}
const onAddExtension = (ext: any) => {
addExtension(ext)
vModel.value = false
}
const activeExtension = computed(() => {
return availableExtensions.value.find((ext) => ext.id === props.extensionId)
})
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '864px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="from === 'extension'"
:footer="null"
:width="1280"
size="medium"
wrap-class-name="nc-modal-extension-market"
>
<div v-if="activeExtension" class="flex flex-col w-full h-full">
<div v-if="from === 'market'" class="h-[40px] flex items-start">
<div class="flex items-center gap-2 pr-2 pb-2 cursor-pointer hover:text-primary" @click="onBack">
<GeneralIcon icon="ncArrowLeft" />
<span>Back</span>
</div>
</div>
<div v-else class="h-[40px]"></div>
<div class="extension-details">
<div class="extension-details-left">
<div class="flex">
<img :src="getExtensionIcon(activeExtension.iconUrl)" alt="icon" class="h-[90px]" />
<div class="flex flex-col p-4">
<div class="font-weight-700 text-2xl">{{ activeExtension.title }}</div>
</div>
</div>
<div class="p-4">
<div class="whitespace-pre-line">{{ activeExtension.description }}</div>
</div>
</div>
<div class="extension-details-right">
<NcButton class="w-full" @click="onAddExtension(activeExtension)">
<div class="flex items-center justify-center">Add Extension</div>
</NcButton>
<div class="flex flex-col gap-1">
<div class="text-md font-weight-600">Version</div>
<div>{{ activeExtension.version }}</div>
</div>
<div class="flex flex-col gap-1">
<div v-if="activeExtension.publisherName" class="text-md font-weight-600">Publisher</div>
<div>{{ activeExtension.publisherName }}</div>
</div>
<div v-if="activeExtension.publisherEmail" class="flex flex-col gap-1">
<div class="text-md font-weight-600">Publisher Email</div>
<div>
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherEmail }}
</a>
</div>
</div>
<div v-if="activeExtension.publisherUrl" class="flex flex-col gap-1">
<div class="text-md font-weight-600">Publisher Website</div>
<div>
<a :href="activeExtension.publisherUrl" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherUrl }}
</a>
</div>
</div>
</div>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped>
.extension-details {
@apply flex w-full h-full;
.extension-details-left {
@apply flex flex-col w-3/4 p-2;
}
.extension-details-right {
@apply w-1/4 p-2 flex flex-col gap-4;
}
}
</style>

231
packages/nc-gui/components/extensions/Extension.vue

@ -0,0 +1,231 @@
<script setup lang="ts">
interface Prop {
extensionId: string
error?: any
}
const { extensionId, error } = defineProps<Prop>()
const { extensionList, extensionsLoaded, availableExtensions, getExtensionIcon, duplicateExtension, showExtensionDetails } =
useExtensions()
const activeError = ref(error)
const extensionModalRef = ref<HTMLElement>()
const extension = computed(() => {
const ext = extensionList.value.find((ext) => ext.id === extensionId)
if (!ext) {
throw new Error('Extension not found')
}
return ext
})
const titleInput = ref<HTMLInputElement | null>(null)
const titleEditMode = ref<boolean>(false)
const tempTitle = ref<string>(extension.value.title)
const enableEditMode = () => {
titleEditMode.value = true
tempTitle.value = extension.value.title
nextTick(() => {
titleInput.value?.focus()
titleInput.value?.select()
titleInput.value?.scrollIntoView()
})
}
const updateExtensionTitle = async () => {
await extension.value.setTitle(tempTitle.value)
titleEditMode.value = false
}
const { fullscreen, collapsed } = useProvideExtensionHelper(extension)
const component = ref<any>(null)
const extensionManifest = ref<any>(null)
onMounted(() => {
until(extensionsLoaded)
.toMatch((v) => v)
.then(() => {
extensionManifest.value = availableExtensions.value.find((ext) => ext.id === extension.value.extensionId)
if (!extensionManifest) {
return
}
import(`../../extensions/${extensionManifest.value.entry}/index.vue`).then((mod) => {
component.value = markRaw(mod.default)
})
})
.catch((err) => {
if (!extensionManifest.value) {
activeError.value = 'There was an error loading the extension'
return
}
activeError.value = err
})
})
// close fullscreen on escape key press
useEventListener('keydown', (e) => {
if (e.key === 'Escape') {
fullscreen.value = false
}
})
// close fullscreen on clicking extensionModalRef directly
const closeFullscreen = (e: MouseEvent) => {
if (e.target === extensionModalRef.value) {
fullscreen.value = false
}
}
</script>
<template>
<div class="w-full p-2">
<div class="extension-wrapper">
<div class="extension-header">
<div class="extension-header-left">
<GeneralIcon icon="drag" />
<img v-if="extensionManifest" :src="getExtensionIcon(extensionManifest.iconUrl)" alt="icon" class="h-6" />
<input
v-if="titleEditMode"
ref="titleInput"
v-model="tempTitle"
class="flex-grow leading-1 outline-0 ring-none capitalize !text-inherit !bg-transparent w-4/5"
@click.stop
@keyup.enter="updateExtensionTitle"
@keyup.esc="updateExtensionTitle"
@blur="updateExtensionTitle"
/>
<div v-else class="extension-title" @dblclick="enableEditMode">{{ extension.title }}</div>
</div>
<div class="extension-header-right">
<GeneralIcon v-if="!activeError" icon="expand" @click="fullscreen = true" />
<NcDropdown :trigger="['click']">
<GeneralIcon icon="threeDotVertical" />
<template #overlay>
<NcMenu>
<template v-if="!activeError">
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="enableEditMode">
<GeneralIcon icon="edit" />
Rename
</NcMenuItem>
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="duplicateExtension(extension.id)">
<GeneralIcon icon="duplicate" />
Duplicate
</NcMenuItem>
<NcMenuItem
data-rec="true"
class="!hover:text-primary"
@click="showExtensionDetails(extension.extensionId, 'extension')"
>
<GeneralIcon icon="info" />
Details
</NcMenuItem>
<NcDivider />
</template>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="extension.clear()">
<GeneralIcon icon="reload" />
Clear Data
</NcMenuItem>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="extension.delete()">
<GeneralIcon icon="delete" />
Delete
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<GeneralIcon v-if="collapsed" icon="arrowUp" @click="collapsed = !collapsed" />
<GeneralIcon v-else icon="arrowDown" @click="collapsed = !collapsed" />
</div>
</div>
<template v-if="activeError">
<div v-show="!collapsed" class="extension-content">
<a-result status="error" title="Extension Error">
<template #subTitle>{{ activeError }}</template>
<template #extra>
<NcButton @click="extension.clear()">
<div class="flex items-center gap-2">
<GeneralIcon icon="reload" />
Clear Data
</div>
</NcButton>
<NcButton type="danger" @click="extension.delete()">
<div class="flex items-center gap-2">
<GeneralIcon icon="delete" />
Delete
</div>
</NcButton>
</template>
</a-result>
</div>
</template>
<template v-else>
<Teleport to="body" :disabled="!fullscreen">
<div ref="extensionModalRef" :class="{ 'extension-modal': fullscreen }" @click="closeFullscreen">
<div :class="{ 'extension-modal-content': fullscreen }">
<div
v-if="fullscreen"
class="flex items-center justify-between p-2 bg-gray-100 rounded-t-lg cursor-default h-[40px]"
>
<div class="flex items-center gap-2 text-gray-500 font-weight-600">
<img v-if="extensionManifest" :src="getExtensionIcon(extensionManifest.iconUrl)" alt="icon" class="w-6 h-6" />
<div class="text-sm">{{ extension.title }}</div>
</div>
<GeneralIcon class="cursor-pointer" icon="close" @click="fullscreen = false" />
</div>
<div
v-show="fullscreen || !collapsed"
class="extension-content"
:class="{ 'border-1': !fullscreen, 'h-[calc(100%-40px)]': fullscreen }"
>
<component :is="component" :key="extension.uiKey" />
</div>
</div>
</div>
</Teleport>
</template>
</div>
</div>
</template>
<style scoped lang="scss">
.extension-wrapper {
@apply bg-white rounded-lg p-2 w-full border-1;
}
.extension-header {
@apply flex justify-between mb-2;
.extension-header-left {
@apply flex items-center gap-2;
}
.extension-header-right {
@apply flex items-center gap-4;
}
.extension-title {
@apply font-weight-600;
}
}
.extension-content {
@apply rounded-lg;
}
.extension-modal {
@apply absolute top-0 left-0 z-50 w-full h-full bg-black bg-opacity-50;
.extension-modal-content {
@apply bg-white rounded-lg w-[90%] h-[90vh] mt-[5vh] mx-auto;
}
}
</style>

72
packages/nc-gui/components/extensions/Market.vue

@ -0,0 +1,72 @@
<script lang="ts" setup>
import { useVModel } from '#imports'
interface Prop {
modelValue?: boolean
}
const props = defineProps<Prop>()
const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionIcon, showExtensionDetails } = useExtensions()
const onExtensionClick = (extensionId: string) => {
showExtensionDetails(extensionId)
vModel.value = false
}
const onAddExtension = (ext: any) => {
addExtension(ext)
vModel.value = false
}
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '864px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="true"
:footer="null"
:width="1280"
size="medium"
wrap-class-name="nc-modal-extension-market"
>
<div class="flex flex-col h-full">
<div class="flex items-center px-4 py-2">
<div class="flex items-center gap-2">
<GeneralIcon icon="puzzle" />
<div class="font-weight-700">Extensions Marketplace</div>
</div>
</div>
<div class="flex flex-col flex-1 px-4 py-2">
<div class="flex flex-wrap gap-4 p-2">
<template v-for="ext of availableExtensions" :key="ext.id">
<div class="flex border-1 rounded-lg p-2 w-[360px] cursor-pointer" @click="onExtensionClick(ext.id)">
<div class="h-[60px] overflow-hidden m-auto">
<img :src="getExtensionIcon(ext.iconUrl)" alt="icon" class="w-full h-full object-cover" />
</div>
<div class="flex flex-grow flex-col ml-3">
<div class="flex justify-between">
<div class="font-weight-600">{{ ext.title }}</div>
<NcButton size="xsmall" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-1 mx-1">
<GeneralIcon icon="plus" />
Add
</div>
</NcButton>
</div>
<div class="w-[250px] h-[50px] text-xs line-clamp-3">{{ ext.description }}</div>
</div>
</div>
</template>
</div>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped></style>

59
packages/nc-gui/components/extensions/Pane.vue

@ -0,0 +1,59 @@
<script setup lang="ts">
import { Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
const { extensionList, isPanelExpanded, isDetailsVisible, detailsExtensionId, detailsFrom, isMarketVisible, extensionPanelSize } =
useExtensions()
const toggleMarket = () => {
isMarketVisible.value = !isMarketVisible.value
}
</script>
<template>
<Pane v-if="isPanelExpanded" :size="extensionPanelSize" class="flex flex-col bg-orange-50">
<div class="flex items-center pl-3 pt-3 font-weight-800 text-orange-500">Extensions</div>
<template v-if="extensionList.length === 0">
<div class="flex items-center flex-col gap-2 w-full nc-scrollbar-md">
<div class="w-[100px] h-[100px] bg-gray-200 rounded-lg mt-[100px]"></div>
<div class="font-weight-700">No extensions added</div>
<div>Add Extensions from the community extensions marketplace</div>
<NcButton @click="toggleMarket">
<div class="flex items-center gap-2 font-weight-600">
<GeneralIcon icon="plus" />
Add Extension
</div>
</NcButton>
</div>
</template>
<template v-else>
<div class="flex w-full items-center justify-between py-2 px-2 bg-orange-50">
<div class="flex flex-grow items-center mr-2">
<a-input type="text" class="!h-8 !px-3 !py-1 !rounded-lg" placeholder="Search Extension">
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
<NcButton type="ghost" size="small" class="!text-primary !bg-white" @click="toggleMarket">
<div class="flex items-center gap-1 px-1 text-xs">
<GeneralIcon icon="plus" />
Add Extension
</div>
</NcButton>
</div>
<div class="flex items-center flex-col w-full nc-scrollbar-md">
<ExtensionsWrapper v-for="ext in extensionList" :key="ext.id" :extension-id="ext.id" />
</div>
</template>
<ExtensionsMarket v-if="isMarketVisible" v-model="isMarketVisible" />
<ExtensionsDetails
v-if="isDetailsVisible && detailsExtensionId"
v-model="isDetailsVisible"
:extension-id="detailsExtensionId"
:from="detailsFrom"
/>
</Pane>
</template>
<style lang="scss"></style>

18
packages/nc-gui/components/extensions/Wrapper.vue

@ -0,0 +1,18 @@
<script setup lang="ts">
interface Prop {
extensionId: string
}
const { extensionId } = defineProps<Prop>()
</script>
<template>
<NuxtErrorBoundary>
<ExtensionsExtension :extension-id="extensionId" />
<template #error="{ error }">
<ExtensionsExtension :extension-id="extensionId" :error="error" />
</template>
</NuxtErrorBoundary>
</template>
<style scoped lang="scss"></style>

3
packages/nc-gui/components/general/BaseIconColorPicker.vue

@ -7,7 +7,7 @@ const props = withDefaults(
defineProps<{
type?: NcProjectType | string
modelValue?: string
size?: 'small' | 'medium' | 'large' | 'xlarge'
size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'
readonly?: boolean
iconClass?: string
}>(),
@ -62,6 +62,7 @@ watch(
:class="{
'hover:bg-gray-500 hover:bg-opacity-15 cursor-pointer': !readonly,
'bg-gray-500 bg-opacity-15': isOpen,
'h-5 w-5 text-base': size === 'xsmall',
'h-6 w-6 text-lg': size === 'small',
'h-8 w-8 text-xl': size === 'medium',
'h-10 w-10 text-2xl': size === 'large',

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

@ -0,0 +1,26 @@
<script setup lang="ts">
import { useCopy } from '~/composables/useCopy'
const props = defineProps<{
content?: string
timeout?: number
}>()
const { copy } = useCopy()
const copied = ref(false)
const copyContent = async () => {
await copy(props.content || '')
copied.value = true
setTimeout(() => {
copied.value = false
}, props.timeout || 2000)
}
</script>
<template>
<NcButton size="xsmall" type="text" @click="copyContent">
<MdiCheck v-if="copied" class="h-3.5" />
<component :is="iconMap.copy" v-else class="text-gray-800" />
</NcButton>
</template>

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

Loading…
Cancel
Save