Browse Source

Merge branch 'develop' into fix/src-filter

fix/src-filter
Raju Udava 7 months ago
parent
commit
8ab0b7a51b
  1. 55
      .github/workflows/bats-test.yml
  2. 506
      docker-compose/setup-script/noco.sh
  3. 31
      docker-compose/setup-script/tests/configure/monitor.bats
  4. 31
      docker-compose/setup-script/tests/configure/restart.bats
  5. 33
      docker-compose/setup-script/tests/configure/scale.bats
  6. 17
      docker-compose/setup-script/tests/configure/setup.sh
  7. 31
      docker-compose/setup-script/tests/configure/start.bats
  8. 30
      docker-compose/setup-script/tests/configure/stop.bats
  9. 31
      docker-compose/setup-script/tests/configure/upgrade.bats
  10. 22
      docker-compose/setup-script/tests/expects/configure/monitor.sh
  11. 20
      docker-compose/setup-script/tests/expects/configure/restart.sh
  12. 23
      docker-compose/setup-script/tests/expects/configure/scale.sh
  13. 20
      docker-compose/setup-script/tests/expects/configure/start.sh
  14. 20
      docker-compose/setup-script/tests/expects/configure/stop.sh
  15. 20
      docker-compose/setup-script/tests/expects/configure/upgrade.sh
  16. 21
      docker-compose/setup-script/tests/expects/install/default.sh
  17. 22
      docker-compose/setup-script/tests/expects/install/ip.sh
  18. 33
      docker-compose/setup-script/tests/expects/install/redis.sh
  19. 33
      docker-compose/setup-script/tests/expects/install/scale.sh
  20. 38
      docker-compose/setup-script/tests/expects/install/ssl.sh
  21. 33
      docker-compose/setup-script/tests/expects/install/watchtower.sh
  22. 36
      docker-compose/setup-script/tests/install/default.bats
  23. 36
      docker-compose/setup-script/tests/install/ip.bats
  24. 32
      docker-compose/setup-script/tests/install/redis.bats
  25. 30
      docker-compose/setup-script/tests/install/scale.bats
  26. 12
      docker-compose/setup-script/tests/install/setup.sh
  27. 30
      docker-compose/setup-script/tests/install/ssl.bats
  28. 30
      docker-compose/setup-script/tests/install/watchtower.bats
  29. 3
      docker-compose/setup-script/tests/mocks/clear
  30. 3
      docker-compose/setup-script/tests/mocks/nproc
  31. 4
      packages/nc-gui/assets/nc-icons/arrow-up-right.svg
  32. 5
      packages/nc-gui/assets/nc-icons/control-panel.svg
  33. 4
      packages/nc-gui/assets/nc-icons/home.svg
  34. 10
      packages/nc-gui/assets/nc-icons/office.svg
  35. 11
      packages/nc-gui/assets/nc-icons/slash.svg
  36. 4
      packages/nc-gui/assets/nc-icons/workspace.svg
  37. 2
      packages/nc-gui/components/cell/Email.vue
  38. 4
      packages/nc-gui/components/cell/Url.vue
  39. 3
      packages/nc-gui/components/dashboard/Sidebar/EEMenuOption.vue
  40. 6
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  41. 4
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  42. 198
      packages/nc-gui/components/dlg/InviteDlg.vue
  43. 3
      packages/nc-gui/components/general/BaseIconColorPicker.vue
  44. 26
      packages/nc-gui/components/general/CopyButton.vue
  45. 2
      packages/nc-gui/components/general/WorkspaceIcon.vue
  46. 7
      packages/nc-gui/components/nc/Badge.vue
  47. 2
      packages/nc-gui/components/nc/ErrorBoundary.vue
  48. 2
      packages/nc-gui/components/nc/Select.vue
  49. 120
      packages/nc-gui/components/project/AccessSettings.vue
  50. 46
      packages/nc-gui/components/project/View.vue
  51. 8
      packages/nc-gui/components/roles/Badge.vue
  52. 8
      packages/nc-gui/components/roles/Selector.vue
  53. 15
      packages/nc-gui/components/smartsheet/Cell.vue
  54. 23
      packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue
  55. 165
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  56. 36
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  57. 41
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  58. 575
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  59. 4
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  60. 19
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  61. 40
      packages/nc-gui/components/smartsheet/grid/Table.vue
  62. 30
      packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts
  63. 2
      packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue
  64. 4
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  65. 2
      packages/nc-gui/components/tabs/Smartsheet.vue
  66. 41
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  67. 69
      packages/nc-gui/components/virtual-cell/HasMany.vue
  68. 62
      packages/nc-gui/components/virtual-cell/Links.vue
  69. 69
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  70. 29
      packages/nc-gui/components/virtual-cell/OneToOne.vue
  71. 2
      packages/nc-gui/components/virtual-cell/QrCode.vue
  72. 79
      packages/nc-gui/components/virtual-cell/components/Header.vue
  73. 85
      packages/nc-gui/components/virtual-cell/components/LinkRecordDropdown.vue
  74. 284
      packages/nc-gui/components/virtual-cell/components/LinkedItems.vue
  75. 199
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  76. 201
      packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue
  77. 162
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  78. 38
      packages/nc-gui/components/workspace/Settings.vue
  79. 91
      packages/nc-gui/components/workspace/View.vue
  80. 3
      packages/nc-gui/composables/useCalendarViewStore.ts
  81. 66
      packages/nc-gui/composables/useData.ts
  82. 2
      packages/nc-gui/composables/useExpandedFormDetached/index.ts
  83. 4
      packages/nc-gui/composables/useExpandedFormStore.ts
  84. 28
      packages/nc-gui/composables/useLTARStore.ts
  85. 23
      packages/nc-gui/composables/useOrganization.ts
  86. 2
      packages/nc-gui/composables/useSmartsheetStore.ts
  87. 11
      packages/nc-gui/composables/useUserSorts.ts
  88. 8
      packages/nc-gui/composables/useViewColumns.ts
  89. 4
      packages/nc-gui/composables/useViewData.ts
  90. 2
      packages/nc-gui/context/index.ts
  91. 70
      packages/nc-gui/lang/ar.json
  92. 70
      packages/nc-gui/lang/bn_IN.json
  93. 70
      packages/nc-gui/lang/cs.json
  94. 70
      packages/nc-gui/lang/da.json
  95. 70
      packages/nc-gui/lang/de.json
  96. 70
      packages/nc-gui/lang/en.json
  97. 70
      packages/nc-gui/lang/es.json
  98. 70
      packages/nc-gui/lang/eu.json
  99. 70
      packages/nc-gui/lang/fa.json
  100. 70
      packages/nc-gui/lang/fi.json
  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

506
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 *********************************
@ -100,9 +118,251 @@ read_number_range() {
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
# ******************************************************************************
@ -116,7 +376,7 @@ 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
@ -135,16 +395,11 @@ for tool in docker 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."
@ -165,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 **************************
# ******************************************************************************
@ -223,7 +459,7 @@ 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
@ -535,211 +771,6 @@ server {
EOF
fi
IS_DOCKER_REQUIRE_SUDO=$(check_for_docker_sudo)
DOCKER_COMMAND=$([ "$IS_DOCKER_REQUIRE_SUDO" = "y" ] && echo "sudo docker" || echo "docker")
# Generate help script
cat > help.sh <<EOF
#!/bin/bash
$(declare -f read_number)
$(declare -f read_number_range)
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'
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
}
# 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 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 "\$service" | wc -l)
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 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
}
# Main program loop
while true; do
trap - INT
show_menu
echo "Enter your choice: "
read 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
EOF
message_arr+=("Help script: help.sh")
cat > ./update.sh <<EOF
$DOCKER_COMMAND compose pull
$DOCKER_COMMAND compose up -d --force-recreate
@ -781,4 +812,11 @@ fi
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

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

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

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

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

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

@ -109,7 +109,7 @@ watch(
<nuxt-link
v-else-if="validEmail"
no-ref
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link"
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/Url.vue

@ -121,7 +121,7 @@ watch(
v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch
no-rel
class="py-1 z-3 underline hover:opacity-75 nc-cell-field-link"
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 nc-cell-field-link"
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"

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

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

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

@ -20,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 {
@ -167,6 +169,8 @@ onMounted(() => {
<NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link>

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"
>

198
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))
}
})
const singleEmailValue = ref('')
const emailBadges = ref<Array<string>>([])
if (props.emails) {
emailBadges.value = props.emails
}
setTimeout(() => {
focusOnDiv()
}, 100)
} else {
emailBadges.value = []
inviteData.email = ''
singleEmailValue.value = ''
}
})
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!, {
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>

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>

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

@ -6,6 +6,7 @@ const props = defineProps<{
workspace: WorkspaceType | undefined
hideLabel?: boolean
size?: 'small' | 'medium' | 'large'
isRounded?: boolean
}>()
const workspaceColor = computed(() => {
@ -24,6 +25,7 @@ const size = computed(() => props.size || 'medium')
'min-w-4 w-4 h-4 rounded': size === 'small',
'min-w-6 w-6 h-6 rounded-md': size === 'medium',
'min-w-10 w-10 h-10 rounded-lg !text-base': size === 'large',
'!rounded-[50%]': props.isRounded,
}"
:style="{ backgroundColor: workspaceColor }"
>

7
packages/nc-gui/components/nc/Badge.vue

@ -4,17 +4,18 @@ const props = withDefaults(
color?: string
border?: boolean
size?: 'sm' | 'md' | 'lg'
rounded?: 'sm' | 'md' | 'lg'
}>(),
{
border: true,
size: 'sm',
rounded: 'md',
},
)
</script>
<template>
<div
class="rounded-md px-1 flex items-center"
:class="{
'border-purple-500 bg-purple-100': props.color === 'purple',
'border-blue-500 bg-blue-100': props.color === 'blue',
@ -28,7 +29,11 @@ const props = withDefaults(
'h-6': props.size === 'sm',
'h-8': props.size === 'md',
'h-10': props.size === 'lg',
'rounded-sm': props.rounded === 'sm',
'rounded-md': props.rounded === 'md',
'rounded-lg': props.rounded === 'lg',
}"
class="px-1 flex items-center"
>
<slot />
</div>

2
packages/nc-gui/components/nc/ErrorBoundary.vue

@ -20,7 +20,7 @@ export default {
onErrorCaptured((err) => {
if (import.meta.client && (!nuxtApp.isHydrating || !nuxtApp.payload.serverRendered)) {
console.log('UI Error :', err)
console.error('UI Error :', err)
emit('error', err)
error.value = err
return false

2
packages/nc-gui/components/nc/Select.vue

@ -3,6 +3,7 @@ const props = defineProps<{
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
size?: 'small' | 'middle' | 'large'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
@ -44,6 +45,7 @@ const onChange = (value: string) => {
<template>
<a-select
v-model:value="vModel"
:size="size"
:allow-clear="allowClear"
:disabled="loading"
:dropdown-class-name="dropdownClassName"

120
packages/nc-gui/components/project/AccessSettings.vue

@ -1,27 +1,44 @@
<script lang="ts" setup>
import {
OrderedProjectRoles,
OrgUserRoles,
ProjectRoles,
WorkspaceRolesToProjectRoles,
extractRolesObj,
parseStringDateTime,
timeAgo,
} from 'nocodb-sdk'
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk'
import { OrderedProjectRoles, OrgUserRoles, ProjectRoles, WorkspaceRolesToProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports'
import { isEeUI, storeToRefs, useUserSorts } from '#imports'
const props = defineProps<{
baseId?: string
}>()
const basesStore = useBases()
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore
const { activeProjectId } = storeToRefs(basesStore)
const { activeProjectId, bases } = storeToRefs(basesStore)
const { orgRoles, baseRoles } = useRoles()
const { orgRoles, baseRoles, loadRoles } = useRoles()
const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Project')
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN])
const orgStore = useOrg()
const { orgId } = storeToRefs(orgStore)
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const { $api } = useNuxtApp()
const currentBase = computedAsync(async () => {
let base
if (props.baseId) {
await loadRoles(props.baseId)
base = bases.value.get(props.baseId)
if (!base) {
base = await $api.base.read(props.baseId!)
}
} else {
base = bases.value.get(activeProjectId.value)
}
return base
})
const isInviteModalVisible = ref(false)
interface Collaborators {
@ -56,8 +73,9 @@ const sortedCollaborators = computed(() => {
const loadCollaborators = async () => {
try {
if (!currentBase.value) return
const { users, totalRows } = await getBaseUsers({
baseId: activeProjectId.value!,
baseId: currentBase.value.id!,
...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)),
force: true,
})
@ -69,9 +87,8 @@ const loadCollaborators = async () => {
.map((user: any) => ({
...user,
base_roles: user.roles,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN]
? OrgUserRoles.SUPER_ADMIN
: user.roles ??
roles:
user.roles ??
(user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS),
@ -93,7 +110,7 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
isEeUI)
) {
await removeProjectUser(activeProjectId.value!, currentCollaborator as unknown as User)
await removeProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
if (
currentCollaborator.workspace_roles &&
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
@ -105,11 +122,11 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
}
} else if (currentCollaborator.base_roles) {
currentCollaborator.roles = roles
await updateProjectUser(activeProjectId.value!, currentCollaborator as unknown as User)
await updateProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
} else {
currentCollaborator.roles = roles
currentCollaborator.base_roles = roles
await createProjectUser(activeProjectId.value!, currentCollaborator as unknown as User)
await createProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -142,24 +159,50 @@ watch(isInviteModalVisible, () => {
loadCollaborators()
}
})
watch(currentBase, () => {
loadCollaborators()
})
</script>
<template>
<div class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]">
<LazyProjectShareBaseDlg v-model:model-value="isInviteModalVisible" />
<div
:class="{
'px-6 ': isAdminPanel,
}"
class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]"
>
<div v-if="isAdminPanel" class="font-bold w-full !mb-5 text-2xl" data-rec="true">
<div class="flex items-center gap-3">
<!-- TODO: @DarkPhoenix2704 -->
<NuxtLink
:href="`/admin/${orgId}/bases`"
class="!hover:(text-black underline-gray-600) !text-black !underline-transparent ml-0.75 max-w-1/4"
>
{{ $t('objects.projects') }}
</NuxtLink>
<span class="text-2xl"> / </span>
<GeneralBaseIconColorPicker readonly />
<span class="text-base">
{{ currentBase?.title }}
</span>
</div>
</div>
<LazyDlgInviteDlg v-model:model-value="isInviteModalVisible" :base-id="currentBase?.id" type="base" />
<div v-if="isLoading" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" />
</div>
<template v-else>
<div class="w-full flex flex-row justify-between items-baseline max-w-350 mt-6.5 mb-2 pr-0.25">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" :placeholder="$t('title.searchMembers')">
<div class="w-full flex flex-row justify-between items-center max-w-350 mt-6.5 mb-2 pr-0.25">
<a-input v-model:value="userSearchText" :placeholder="$t('title.searchMembers')" class="!max-w-90 !rounded-md mr-4">
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<NcButton size="small" @click="isInviteModalVisible = true">
<div class="flex gap-1">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" class="w-4 h-4" />
{{ $t('activity.addMembers') }}
</div>
@ -188,7 +231,7 @@ watch(isInviteModalVisible, () => {
<div class="text-gray-700 user-access-grid flex items-center space-x-2">
<span>
{{ $t('general.access') }}
{{ $t('general.role') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
@ -203,18 +246,17 @@ watch(isInviteModalVisible, () => {
>
<div class="flex gap-3 items-center users-email-grid">
<GeneralUserIcon size="base" :email="collab.email" />
<NcTooltip v-if="collab.display_name">
<template #title>
{{ collab.email }}
</template>
<span class="truncate">
{{ collab.display_name }}
<div class="flex flex-col">
<div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }}
</span>
</NcTooltip>
<span v-else class="truncate">
</div>
<span class="text-xs text-gray-600">
{{ collab.email }}
</span>
</div>
</div>
<div class="user-access-grid">
<template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector
@ -230,7 +272,7 @@ watch(isInviteModalVisible, () => {
/>
</template>
<template v-else>
<RolesBadge :role="collab.roles" />
<RolesBadge :border="false" :role="collab.roles" />
</template>
</div>
<div class="date-joined-grid">
@ -252,6 +294,18 @@ watch(isInviteModalVisible, () => {
</template>
<style scoped lang="scss">
.ant-input::placeholder {
@apply text-gray-500;
}
.ant-input:placeholder-shown {
@apply text-gray-500 !text-md;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
}
.color-band {
@apply w-6 h-6 left-0 top-2.5 rounded-full flex justify-center uppercase text-white font-weight-bold text-xs items-center;
}

46
packages/nc-gui/components/project/View.vue

@ -3,22 +3,36 @@ import { useTitle } from '@vueuse/core'
import NcLayout from '~icons/nc-icons/layout'
import { isEeUI } from '#imports'
const props = defineProps<{
baseId: string
}>()
const basesStore = useBases()
const { openedProject, activeProjectId, basesUser } = storeToRefs(basesStore)
const { openedProject, activeProjectId, basesUser, bases } = storeToRefs(basesStore)
const { activeTables, activeTable } = storeToRefs(useTablesStore())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace())
const { navigateToProjectPage } = useBase()
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const router = useRouter()
const route = router.currentRoute
const { $e } = useNuxtApp()
const { $e, $api } = useNuxtApp()
const currentBase = computed(async () => {
let base
if (props.baseId) {
base = bases.value.get(props.baseId)
if (!base) base = await $api.base.read(props.baseId!)
} else {
base = openedProject.value
}
/* const defaultBase = computed(() => {
return openedProject.value?.sources?.[0]
}) */
return base
})
const { isUIAllowed, baseRoles } = useRoles()
@ -37,7 +51,7 @@ const userCount = computed(() =>
watch(
() => route.value.query?.page,
(newVal, oldVal) => {
if (route.value.name !== 'index-typeOrId-baseId-index-index') return
// if (route.value.name !== 'index-typeOrId-baseId-index-index') return
if (newVal && newVal !== oldVal) {
if (newVal === 'collaborator') {
projectPageTab.value = 'collaborator'
@ -46,11 +60,14 @@ watch(
} else {
projectPageTab.value = 'allTable'
}
return
}
if (isAdminPanel.value) {
projectPageTab.value = 'collaborator'
} else {
projectPageTab.value = 'allTable'
}
},
{ immediate: true },
)
@ -66,11 +83,11 @@ watch(projectPageTab, () => {
})
watch(
() => [openedProject.value?.id, openedProject.value?.title],
() => [currentBase.value?.id, currentBase.value?.title],
() => {
if (activeTable.value?.title) return
useTitle(`${openedProject.value?.title ?? activeWorkspace.value?.title ?? 'NocoDB'}`)
useTitle(`${currentBase.value?.title ?? activeWorkspace.value?.title ?? 'NocoDB'}`)
},
{
immediate: true,
@ -81,17 +98,18 @@ watch(
<template>
<div class="h-full nc-base-view">
<div
v-if="!isAdminPanel"
class="flex flex-row pl-2 pr-2 gap-1 border-b-1 border-gray-200 justify-between w-full"
:class="{ 'nc-table-toolbar-mobile': isMobileMode, 'h-[var(--topbar-height)]': !isMobileMode }"
>
<div class="flex flex-row items-center gap-x-3">
<GeneralOpenLeftSidebarBtn />
<div class="flex flex-row items-center h-full gap-x-2.5">
<GeneralProjectIcon :type="openedProject?.type" :color="parseProp(openedProject?.meta).iconColor" />
<GeneralProjectIcon :color="parseProp(currentBase?.meta).iconColor" :type="currentBase?.type" />
<NcTooltip class="flex font-medium text-sm capitalize truncate max-w-150" show-on-truncate-only>
<template #title> {{ openedProject?.title }}</template>
<template #title> {{ currentBase?.title }}</template>
<span class="truncate">
{{ openedProject?.title }}
{{ currentBase?.title }}
</span>
</NcTooltip>
</div>
@ -105,7 +123,7 @@ watch(
}"
>
<a-tabs v-model:activeKey="projectPageTab" class="w-full">
<a-tab-pane key="allTable">
<a-tab-pane v-if="!isAdminPanel" key="allTable">
<template #tab>
<div class="tab-title" data-testid="proj-view-tab__all-tables">
<NcLayout />
@ -143,7 +161,7 @@ watch(
</div>
</div>
</template>
<ProjectAccessSettings />
<ProjectAccessSettings :base-id="currentBase.id" />
</a-tab-pane>
<a-tab-pane v-if="isUIAllowed('sourceCreate')" key="data-source">
<template #tab>

8
packages/nc-gui/components/roles/Badge.vue

@ -8,6 +8,8 @@ const props = withDefaults(
clickable?: boolean
inherit?: boolean
border?: boolean
showIcon?: boolean
iconOnly?: boolean
size?: 'sm' | 'md' | 'lg'
}>(),
{
@ -15,6 +17,8 @@ const props = withDefaults(
inherit: false,
border: true,
size: 'sm',
iconOnly: false,
showIcon: true,
},
)
@ -60,8 +64,8 @@ const roleProperties = computed(() => {
}"
>
<div class="flex items-center gap-2">
<GeneralIcon :icon="roleProperties.icon" />
<span class="flex whitespace-nowrap">
<GeneralIcon v-if="showIcon" :icon="roleProperties.icon" />
<span v-if="!iconOnly" class="flex whitespace-nowrap">
{{ $t(`objects.roleType.${roleProperties.label}`) }}
</span>
</div>

8
packages/nc-gui/components/roles/Selector.vue

@ -1,11 +1,12 @@
<script lang="ts" setup>
import { RoleDescriptions } from 'nocodb-sdk'
import type { RoleLabels } from 'nocodb-sdk'
import { RoleDescriptions } from 'nocodb-sdk'
import type { SelectValue } from 'ant-design-vue/es/select'
import { toRef } from '#imports'
const props = withDefaults(
defineProps<{
border?: boolean
role: keyof typeof RoleLabels
roles: (keyof typeof RoleLabels)[]
description?: boolean
@ -14,6 +15,7 @@ const props = withDefaults(
size?: 'sm' | 'md' | 'lg'
}>(),
{
border: true,
description: true,
size: 'sm',
},
@ -36,7 +38,7 @@ function onChangeRole(val: SelectValue) {
<template>
<div ref="dropdownRef" size="lg" class="nc-roles-selector relative" @click="isDropdownOpen = !isDropdownOpen">
<RolesBadge data-testid="roles" :role="roleRef" :inherit="inheritRef === role" :size="sizeRef" clickable />
<RolesBadge :border="false" :inherit="inheritRef === role" :role="roleRef" :size="sizeRef" clickable data-testid="roles" />
<a-select
:value="roleRef"
:open="isDropdownOpen"
@ -54,7 +56,7 @@ function onChangeRole(val: SelectValue) {
class="flex flex-col nc-role-select-dropdown gap-1"
>
<div class="flex items-center justify-between">
<RolesBadge :class="`nc-role-select-${rl}`" :role="rl" :inherit="inheritRef === rl" :border="false" />
<RolesBadge :border="false" :class="`nc-role-select-${rl}`" :inherit="inheritRef === rl" :role="rl" />
<GeneralIcon v-if="rl === roleRef" icon="check" class="text-primary" />
</div>
<div v-if="descriptionRef" class="text-gray-500 text-xs">{{ RoleDescriptions[rl] }}</div>

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

@ -112,6 +112,17 @@ const syncValue = useDebounceFn(
{ maxWait: 2000 },
)
let saveTimer: number
const updateWhenEditCompleted = () => {
if (editEnabled.value) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(updateWhenEditCompleted, 500)
} else {
emit('save')
}
}
const vModel = computed({
get: () => {
return props.modelValue
@ -122,7 +133,9 @@ const vModel = computed({
} else if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true
emit('update:modelValue', val)
if (isAutoSaved(column.value)) {
if (column.value.pk) {
updateWhenEditCompleted()
} else if (isAutoSaved(column.value)) {
syncValue()
} else if (!isManualSaved(column.value)) {
emit('save')

23
packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue

@ -18,16 +18,23 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType) => {
const fi = _fields.value?.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
// We loop through all the records and calculate the position of each record based on the range
// We only need to calculate the top, of the record since there is no overlap in the day view of date Field
const recordsAcrossAllRange = computed<Row[]>(() => {

165
packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue

@ -28,15 +28,22 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType) => {
if (!_fields.value) return { underline: false, bold: false, italic: false }
const fi = _fields.value.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
const hours = computed(() => {
@ -49,7 +56,8 @@ const hours = computed(() => {
return hours
})
const calculateNewDates = ({
const calculateNewDates = useMemoize(
({
endDate,
startDate,
scheduleStart,
@ -78,7 +86,8 @@ const calculateNewDates = ({
}
return { endDate, startDate }
}
},
)
const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute()
@ -133,35 +142,14 @@ const hasSlotForRecord = (
}
const getMaxOverlaps = ({
row,
gridTimeMap,
columnArray,
graph,
}: {
row: Row
gridTimeMap: Map<
number,
{
count: number
id: string[]
}
>
columnArray: Array<Array<Row>>
graph: Map<string, Set<string>>
}) => {
const visited: Set<string> = new Set()
const graph: Map<string, Set<string>> = new Map()
// Build the graph
for (const [_gridTime, { id: ids }] of gridTimeMap) {
for (const id1 of ids) {
if (!graph.has(id1)) {
graph.set(id1, new Set())
}
for (const id2 of ids) {
if (id1 !== id2) {
graph.get(id1)!.add(id2)
}
}
}
}
const dfs = (id: string): number => {
visited.add(id)
@ -169,6 +157,7 @@ const getMaxOverlaps = ({
const neighbors = graph.get(id)
if (neighbors) {
for (const neighbor of neighbors) {
if (maxOverlaps >= columnArray.length) return maxOverlaps
if (!visited.has(neighbor)) {
maxOverlaps = Math.min(Math.max(maxOverlaps, dfs(neighbor) + 1), columnArray.length)
}
@ -187,32 +176,19 @@ const getMaxOverlaps = ({
const recordsAcrossAllRange = computed<{
record: Row[]
count: {
[key: string]: {
gridTimeMap: Map<
number,
{
count: number
id: string[]
overflow: boolean
overflowCount: number
}
}
>
}>(() => {
if (!calendarRange.value || !formattedData.value) return { record: [], count: {} }
const scheduleStart = dayjs(selectedDate.value).startOf('day')
const scheduleEnd = dayjs(selectedDate.value).endOf('day')
// We use this object to keep track of the number of records that overlap at a given time, and if the number of records exceeds 4, we hide the record
// and show a button to view more records
// The key is the time in HH:mm format
// id is the id of the record generated below
const overlaps: {
[key: string]: {
id: string[]
overflow: boolean
overflowCount: number
}
} = {}
const perRecordHeight = 52
const columnArray: Array<Array<Row>> = [[]]
@ -400,11 +376,28 @@ const recordsAcrossAllRange = computed<{
record.rowMeta.overLapIteration = parseInt(columnIndex) + 1
}
}
const graph = new Map<string, Set<string>>()
// Build the graph
for (const [_gridTime, { id: ids }] of gridTimeMap) {
for (const id1 of ids) {
if (!graph.has(id1)) {
graph.set(id1, new Set())
}
for (const id2 of ids) {
if (id1 !== id2) {
graph.get(id1)!.add(id2)
}
}
}
}
for (const record of recordsByRange) {
const numberOfOverlaps = getMaxOverlaps({
row: record,
gridTimeMap,
columnArray,
graph,
})
record.rowMeta.numberOfOverlaps = numberOfOverlaps
@ -418,24 +411,6 @@ const recordsAcrossAllRange = computed<{
if (record.rowMeta.overLapIteration! - 1 > 7) {
display = 'none'
gridTimeMap.forEach((value, key) => {
if (value.id.includes(record.rowMeta.id!)) {
if (!overlaps[key]) {
overlaps[key] = {
id: value.id,
overflow: true,
overflowCount: value.id.length,
}
} else {
overlaps[key].overflow = true
value.id.forEach((id) => {
if (!overlaps[key].id.includes(id)) {
overlaps[key].id.push(id)
}
})
}
}
})
} else {
left = width * (record.rowMeta.overLapIteration! - 1)
}
@ -453,7 +428,7 @@ const recordsAcrossAllRange = computed<{
}
return {
count: overlaps,
gridTimeMap,
record: recordsByRange,
}
})
@ -477,7 +452,7 @@ const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[],
}, 500)
// When the user is dragging a record, we calculate the new start and end date based on the mouse position
const calculateNewRow = (event: MouseEvent) => {
const calculateNewRow = (event: MouseEvent, skipChangeCheck?: boolean) => {
if (!container.value || !dragRecord.value) return { newRow: null, updateProperty: [] }
const { top } = container.value.getBoundingClientRect()
@ -505,7 +480,7 @@ const calculateNewRow = (event: MouseEvent) => {
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
[fromCol.title!]: dayjs(newStartDate).utc().format('YYYY-MM-DD HH:mm:ssZ'),
},
}
@ -528,11 +503,16 @@ const calculateNewRow = (event: MouseEvent) => {
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
newRow.row[toCol.title!] = dayjs(endDate).utc().format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.push(toCol.title!)
}
// If from and to columns of the dragRecord and the newRow are the same, we don't manipulate the formattedRecords and formattedSideBarData. This removes unwanted computation
if (dragRecord.value.row[fromCol.title!] === newRow.row[fromCol.title!] && !skipChangeCheck) {
return { newRow: null, updateProperty: [] }
}
if (!newRow) {
return { newRow: null, updateProperty: [] }
}
@ -552,6 +532,11 @@ const calculateNewRow = (event: MouseEvent) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk
})
dragRecord.value = {
...dragRecord.value,
row: newRow.row,
}
}
return { newRow, updateProperty }
}
@ -668,7 +653,7 @@ const stopDrag = (event: MouseEvent) => {
clearTimeout(dragTimeout.value!)
if (!isUIAllowed('dataEdit') || !isDragging.value || !container.value || !dragRecord.value) return
const { newRow, updateProperty } = calculateNewRow(event)
const { newRow, updateProperty } = calculateNewRow(event, true)
if (!newRow && !updateProperty) return
const allRecords = document.querySelectorAll('.draggable-record')
@ -823,32 +808,18 @@ const dropEvent = (event: DragEvent) => {
}
const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => {
let startOfHour = hour.startOf('hour')
const endOfHour = hour.endOf('hour')
const ids: Array<string> = []
let isOverflow = false
if (!recordsAcrossAllRange.value || !recordsAcrossAllRange.value.gridTimeMap) return { isOverflow: false, overflowCount: 0 }
const { gridTimeMap } = recordsAcrossAllRange.value
const startMinute = hour.hour() * 60 + hour.minute()
const endMinute = hour.hour() * 60 + hour.minute() + 59
let overflowCount = 0
while (startOfHour.isBefore(endOfHour, 'minute')) {
const hourKey = startOfHour.hour() * 60 + startOfHour.minute()
if (recordsAcrossAllRange.value?.count?.[hourKey]?.overflow) {
isOverflow = true
recordsAcrossAllRange.value?.count?.[hourKey]?.id.forEach((id) => {
if (!ids.includes(id)) {
ids.push(id)
overflowCount += 1
}
})
for (let minute = startMinute; minute <= endMinute; minute++) {
const recordCount = gridTimeMap.get(minute)?.count ?? 0
overflowCount = Math.max(overflowCount, recordCount)
}
startOfHour = startOfHour.add(1, 'minute')
}
overflowCount = overflowCount > 8 ? overflowCount - 8 : 0
return { isOverflow, overflowCount }
return { isOverflow: overflowCount - 8 > 0, overflowCount: overflowCount - 8 }
}
const viewMore = (hour: dayjs.Dayjs) => {

36
packages/nc-gui/components/smartsheet/calendar/MonthView.vue

@ -64,15 +64,22 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => {
if (!field) return { underline: false, bold: false, italic: false }
const fi = _fields.value?.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
const dates = computed(() => {
@ -343,7 +350,7 @@ const recordsToDisplay = computed<{
}
})
const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean, skipChangeCheck?: boolean) => {
const { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect()
const percentY = (event.clientY - top - window.scrollY) / height
@ -364,7 +371,7 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
...dragRecord.value,
row: {
...dragRecord.value?.row,
[fromCol!.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
[fromCol!.title!]: dayjs(newStartDate).utc().format('YYYY-MM-DD HH:mm:ssZ'),
},
}
@ -384,10 +391,15 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
endDate = newStartDate.clone()
}
newRow.row[toCol!.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
newRow.row[toCol!.title!] = dayjs(endDate).utc().format('YYYY-MM-DD HH:mm:ssZ')
updateProperty.push(toCol!.title!)
}
// If from and to columns of the dragRecord and the newRow are the same, we don't manipulate the formattedRecords and formattedSideBarData. This removes unwanted computation
if (dragRecord.value.row[fromCol.title!] === newRow.row[fromCol.title!] && !skipChangeCheck) {
return { newRow: null, updatedProperty: [] }
}
if (!newRow) return { newRow: null, updateProperty: [] }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -515,7 +527,7 @@ const stopDrag = (event: MouseEvent) => {
event.preventDefault()
dragElement.value!.style.boxShadow = 'none'
const { newRow, updateProperty } = calculateNewRow(event, false)
const { newRow, updateProperty } = calculateNewRow(event, false, true)
const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => {

41
packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue

@ -1,6 +1,6 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { type ColumnType } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import { computed, ref, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
@ -22,14 +22,22 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => {
const fi = _fields.value?.find((f) => f.title === field?.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
}
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
// Calculate the dates of the week
@ -71,6 +79,18 @@ const findFirstSuitableRow = (recordsInDay: any, startDayIndex: number, spanDays
}
}
const isInRange = (date: dayjs.Dayjs) => {
return (
date &&
date.isBetween(
dayjs(selectedDateRange.value.start).startOf('day'),
dayjs(selectedDateRange.value.end).endOf('day'),
'day',
'[]',
)
)
}
const calendarData = computed(() => {
if (!formattedData.value || !calendarRange.value) return []
@ -156,9 +176,8 @@ const calendarData = computed(() => {
let position = 'none'
const isStartInRange =
ogStartDate && ogStartDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]')
const isEndInRange = endDate && endDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]')
const isStartInRange = isInRange(ogStartDate)
const isEndInRange = isInRange(endDate)
// Calculate the position of the record in the calendar based on the start and end date
// The position can be 'none', 'leftRounded', 'rightRounded', 'rounded'

575
packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue

@ -1,8 +1,8 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { type ColumnType } from 'nocodb-sdk'
import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib'
import { computed, ref, useViewColumnsOrThrow } from '#imports'
import { computed, ref, useMemoize, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils'
const emits = defineEmits(['expandRecord', 'newRecord'])
@ -14,7 +14,6 @@ const {
calendarRange,
displayField,
selectedTime,
selectedDate,
updateRowProperty,
sideBarFilterOption,
showSideMenu,
@ -34,17 +33,54 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => {
if (!field) return { underline: false, bold: false, italic: false }
const fi = _fields.value?.find((f) => f.title === field.title)
const fieldStyles = computed(() => {
if (!_fields.value) return new Map()
return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return {
underline: fi?.underline,
bold: fi?.bold,
italic: fi?.italic,
const getFieldStyle = (field: ColumnType) => {
return fieldStyles.value.get(field.id)
}
const calculateNewDates = useMemoize(
({
startDate,
endDate,
scheduleStart,
scheduleEnd,
}: {
startDate: dayjs.Dayjs
endDate: dayjs.Dayjs
scheduleStart: dayjs.Dayjs
scheduleEnd: dayjs.Dayjs
}) => {
// If the end date is not valid, we set it to 15 minutes after the start date
if (!endDate?.isValid()) {
endDate = startDate.clone().add(15, 'minutes')
}
// If the start date is before the start of the schedule, we set it to the start of the schedule
// If the end date is after the end of the schedule, we set it to the end of the schedule
// This is to ensure that the records are within the bounds of the schedule and do not overflow
if (startDate.isBefore(scheduleStart, 'minutes')) {
startDate = scheduleStart.clone()
}
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd.clone()
}
return { startDate, endDate }
},
)
// Since it is a datetime Week view, we need to create a 2D array of dayjs objects to represent the hours in a day for each day in the week
const datesHours = computed(() => {
const datesHours: Array<Array<dayjs.Dayjs>> = []
@ -71,167 +107,178 @@ const datesHours = computed(() => {
return datesHours
})
const recordsAcrossAllRange = computed<{
records: Array<Row>
count: {
[key: string]: {
[key: string]: {
id: Array<string>
overflow: boolean
overflowCount: number
const getDayIndex = (date: dayjs.Dayjs) => {
let dayIndex = date.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
return dayIndex
}
const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute()
if (round) {
return Math.ceil(gridCalc)
} else {
return Math.floor(gridCalc)
}
}>(() => {
if (!formattedData.value || !calendarRange.value || !container.value || !scrollContainer.value)
return {
records: [],
count: {},
}
const perWidth = containerWidth.value / 7
const perHeight = 52
const scheduleStart = dayjs(selectedDateRange.value.start).startOf('day')
const scheduleEnd = dayjs(selectedDateRange.value.end).endOf('day')
// We need to keep track of the overlaps for each day and hour, minute in the week to calculate the width and left position of each record
// The first key is the date, the second key is the hour, and the value is an object containing the ids of the records that overlap
// The key is in the format YYYY-MM-DD and the hour is in the format HH:mm
const overlaps: {
[key: string]: {
[key: string]: {
id: Array<string>
overflow: boolean
overflowCount: number
const getGridTimeSlots = (from: dayjs.Dayjs, to: dayjs.Dayjs) => {
return {
from: getGridTime(from, false),
to: getGridTime(to, true) - 1,
dayIndex: getDayIndex(from),
}
}
} = {}
let recordsToDisplay: Array<Row> = []
const hasSlotForRecord = (
columnArray: Row[],
dates: {
fromDate: dayjs.Dayjs
toDate: dayjs.Dayjs
},
) => {
const { fromDate, toDate } = dates
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
if (!fromDate || !toDate) return false
// We fetch all the records that match the calendar ranges in a single time.
// But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value].filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
for (const column of columnArray) {
const columnFromCol = column.rowMeta.range?.fk_from_col
const columnToCol = column.rowMeta.range?.fk_to_col
if (fromCol && toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
if (!columnFromCol) return false
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (fromCol && !toCol) {
return !!fromDate
}
return false
const { startDate: columnFromDate, endDate: columnToDate } = calculateNewDates({
startDate: dayjs(column.row[columnFromCol.title!]),
endDate: columnToCol
? dayjs(column.row[columnToCol.title!])
: dayjs(column.row[columnFromCol.title!]).add(1, 'hour').subtract(1, 'minute'),
scheduleStart: dayjs(selectedDateRange.value.start).startOf('day'),
scheduleEnd: dayjs(selectedDateRange.value.end).endOf('day'),
})
sortedFormattedData.forEach((record: Row) => {
if (!toCol && fromCol) {
// If there is no toColumn chosen in the range
const ogStartDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
if (!ogStartDate) return
let endDate = ogStartDate.clone().add(1, 'hour')
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd
if (
fromDate.isBetween(columnFromDate, columnToDate, null, '[]') ||
toDate.isBetween(columnFromDate, columnToDate, null, '[]')
) {
return false
}
}
return true
}
const id = record.rowMeta.id ?? generateRandomNumber()
let startDate = ogStartDate.clone()
const getMaxOverlaps = ({
row,
columnArray,
graph,
}: {
row: Row
columnArray: Array<Array<Array<Row>>>
graph: Map<string, Set<string>>
}) => {
const id = row.rowMeta.id as string
let style: Partial<CSSStyleDeclaration> = {}
const visited: Set<string> = new Set()
while (startDate.isBefore(endDate, 'minutes')) {
const dateKey = startDate?.format('YYYY-MM-DD')
const hourKey = startDate?.format('HH:mm')
const dayIndex = row.rowMeta.dayIndex
const overlapIndex = columnArray[dayIndex].findIndex((column) => column.findIndex((r) => r.rowMeta.id === id) !== -1) + 1
// If the dateKey and hourKey are valid, we add the id to the overlaps object
if (dateKey && hourKey) {
if (!overlaps[dateKey]) {
overlaps[dateKey] = {}
const dfs = (id: string): number => {
visited.add(id)
let maxOverlaps = 1
const neighbors = graph.get(id)
if (neighbors) {
for (const neighbor of neighbors) {
if (maxOverlaps >= columnArray[dayIndex].length) return maxOverlaps
if (!visited.has(neighbor)) {
maxOverlaps = Math.min(Math.max(maxOverlaps, dfs(neighbor) + 1), columnArray[dayIndex].length)
}
if (!overlaps[dateKey][hourKey]) {
overlaps[dateKey][hourKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[dateKey][hourKey].id.push(id)
return maxOverlaps
}
// If the number of records that overlap in a single hour is more than 4, we hide the record and set the overflow flag to true
// We also keep track of the number of records that overflow
if (overlaps[dateKey][hourKey].id.length > 4) {
overlaps[dateKey][hourKey].overflow = true
style.display = 'none'
overlaps[dateKey][hourKey].overflowCount += 1
let maxOverlaps = 1
if (graph.has(id)) {
maxOverlaps = dfs(id)
}
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = dayjs(dateKey).day() - 1
if (dayIndex === -1) {
dayIndex = 6
return { maxOverlaps, dayIndex, overlapIndex }
}
startDate = startDate.add(1, 'minute')
const recordsAcrossAllRange = computed<{
records: Array<Row>
gridTimeMap: Map<
number,
Map<
number,
{
count: number
id: string[]
}
let dayIndex = ogStartDate.day() - 1
if (dayIndex === -1) {
dayIndex = 6
>
>
}>(() => {
if (!formattedData.value || !calendarRange.value || !container.value || !scrollContainer.value)
return {
records: [],
gridTimeMap: new Map(),
}
const perWidth = containerWidth.value / 7
const perHeight = 52
const minutes = (ogStartDate.minute() / 60 + ogStartDate.hour()) * 52
const scheduleStart = dayjs(selectedDateRange.value.start).startOf('day')
const scheduleEnd = dayjs(selectedDateRange.value.end).endOf('day')
style = {
...style,
top: `${minutes + 1}px`,
height: `${perHeight - 2}px`,
const columnArray: Array<Array<Array<Row>>> = [[[]]]
const gridTimeMap = new Map<
number,
Map<
number,
{
count: number
id: string[]
}
>
>()
const recordsToDisplay: Array<Row> = []
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
position: 'rounded',
style,
range,
dayIndex,
},
})
} else if (fromCol && toCol) {
const id = record.rowMeta.id ?? generateRandomNumber()
calendarRange.value.forEach((range) => {
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
let startDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
let endDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
// We fetch all the records that match the calendar ranges in a single time.
// But not all fetched records are valid for the certain range, so we filter them out & sort them
const sortedFormattedData = [...formattedData.value]
.filter((record) => {
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
// If the start date is not valid, we skip the record
if (!startDate?.isValid()) return
if (fromCol && toCol) {
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
// If the end date is not valid, we set it to 30 minutes after the start date
if (!endDate?.isValid()) {
endDate = startDate.clone().add(30, 'minutes')
return fromDate && toDate && !toDate.isBefore(fromDate)
} else if (fromCol && !toCol) {
return !!fromDate
}
return false
})
.sort((a, b) => {
const aDate = dayjs(a.row[fromCol!.title!])
const bDate = dayjs(b.row[fromCol!.title!])
return aDate.isBefore(bDate) ? 1 : -1
})
// If the start date is before the start of the schedule, we set it to the start of the schedule
// If the end date is after the end of the schedule, we set it to the end of the schedule
// This is to ensure that the records are within the bounds of the schedule and do not overflow
if (startDate.isBefore(scheduleStart, 'minutes')) {
startDate = scheduleStart
}
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd
}
for (const record of sortedFormattedData) {
const id = record.rowMeta.id ?? generateRandomNumber()
if (fromCol && toCol) {
const { startDate, endDate } = calculateNewDates({
startDate: dayjs(record.row[fromCol.title!]),
endDate: dayjs(record.row[toCol.title!]),
scheduleStart,
scheduleEnd,
})
// Setting the current start date to the start date of the record
let currentStartDate: dayjs.Dayjs = startDate.clone()
@ -242,14 +289,7 @@ const recordsAcrossAllRange = computed<{
const recordStart: dayjs.Dayjs = currentEndDate.isSame(startDate, 'day') ? startDate : currentStartDate
const recordEnd = currentEndDate.isSame(endDate, 'day') ? endDate : currentEndDate
const dateKey = recordStart.format('YYYY-MM-DD')
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = recordStart.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
const dayIndex = getDayIndex(recordStart)
// We calculate the index of the start and end hour in the day
const startHourIndex = Math.max(
@ -278,36 +318,8 @@ const recordsAcrossAllRange = computed<{
position = 'none'
}
let _startHourIndex = startHourIndex
let style: Partial<CSSStyleDeclaration> = {}
// We loop through the start hour index to the end hour index and add the id to the overlaps object
while (_startHourIndex <= endHourIndex) {
const hourKey = datesHours.value[dayIndex][_startHourIndex].format('HH:mm')
if (!overlaps[dateKey]) {
overlaps[dateKey] = {}
}
if (!overlaps[dateKey][hourKey]) {
overlaps[dateKey][hourKey] = {
id: [],
overflow: false,
overflowCount: 0,
}
}
overlaps[dateKey][hourKey].id.push(id)
// If the number of records that overlap in a single hour is more than 4, we hide the record and set the overflow flag to true
// We also keep track of the number of records that overflow
if (overlaps[dateKey][hourKey].id.length > 4) {
overlaps[dateKey][hourKey].overflow = true
style.display = 'none'
overlaps[dateKey][hourKey].overflowCount += 1
}
_startHourIndex++
}
const spanHours = endHourIndex - startHourIndex + 1
const top = startHourIndex * perHeight
@ -334,45 +346,170 @@ const recordsAcrossAllRange = computed<{
// We set the current start date to the next day
currentStartDate = currentStartDate.add(1, 'day').hour(0).minute(0)
}
} else if (fromCol) {
// If there is no toColumn chosen in the range
const { startDate } = calculateNewDates({
startDate: dayjs(record.row[fromCol.title!]),
endDate: dayjs(record.row[fromCol.title!]).add(1, 'hour').subtract(1, 'minute'),
scheduleStart,
scheduleEnd,
})
let style: Partial<CSSStyleDeclaration> = {}
const dayIndex = getDayIndex(startDate)
const minutes = (startDate.minute() / 60 + startDate.hour()) * perHeight
style = {
...style,
top: `${minutes + 1}px`,
height: `${perHeight - 2}px`,
}
recordsToDisplay.push({
...record,
rowMeta: {
...record.rowMeta,
id,
position: 'rounded',
style,
range,
dayIndex,
},
})
}
}
recordsToDisplay.sort((a, b) => {
const fromColA = a.rowMeta.range?.fk_from_col
const fromColB = b.rowMeta.range?.fk_from_col
if (!fromColA || !fromColB) return 0
return dayjs(a.row[fromColA.title!]).isBefore(dayjs(b.row[fromColB.title!])) ? -1 : 1
})
// With can't find the left and width of the record without knowing the overlaps
// Hence the first iteration is to find the overlaps, top, height and then the second iteration is to find the left and width
// This is because the left and width of the record depends on the overlaps
for (const record of recordsToDisplay) {
const fromCol = record.rowMeta.range?.fk_from_col
const toCol = record.rowMeta.range?.fk_to_col
recordsToDisplay = recordsToDisplay.map((record) => {
// maxOverlaps is the maximum number of records that overlap in a single hour
// overlapIndex is the index of the record in the overlaps object
let maxOverlaps = 1
let overlapIndex = 0
const dayIndex = record.rowMeta.dayIndex as number
if (!fromCol) continue
const { startDate, endDate } = calculateNewDates({
startDate: dayjs(record.row[fromCol.title!]),
endDate: toCol ? dayjs(record.row[toCol.title!]) : dayjs(record.row[fromCol.title!]).add(1, 'hour').subtract(1, 'minute'),
scheduleStart,
scheduleEnd,
})
const gridTimes = getGridTimeSlots(startDate, endDate)
const dayIndex = record.rowMeta.dayIndex ?? gridTimes.dayIndex
for (let gridCounter = gridTimes.from; gridCounter <= gridTimes.to; gridCounter++) {
if (!gridTimeMap.has(dayIndex)) {
gridTimeMap.set(
dayIndex,
new Map<
number,
{
count: number
id: string[]
}
>(),
)
}
if (!gridTimeMap.get(dayIndex)?.has(gridCounter)) {
gridTimeMap.set(dayIndex, (gridTimeMap.get(dayIndex) ?? new Map()).set(gridCounter, { count: 0, id: [] }))
}
const idArray = gridTimeMap.get(dayIndex)!.get(gridCounter)!.id
idArray.push(record.rowMeta.id!)
const count = gridTimeMap.get(dayIndex)!.get(gridCounter)!.count + 1
gridTimeMap.set(dayIndex, (gridTimeMap.get(dayIndex) ?? new Map()).set(gridCounter, { count, id: idArray }))
}
let foundAColumn = false
if (!columnArray[dayIndex]) {
columnArray[dayIndex] = []
}
for (const column in columnArray[dayIndex]) {
if (hasSlotForRecord(columnArray[dayIndex][column], { fromDate: startDate, toDate: endDate })) {
columnArray[dayIndex][column].push(record)
foundAColumn = true
break
}
}
if (!foundAColumn) {
columnArray[dayIndex].push([record])
}
}
const graph: Map<number, Map<string, Set<string>>> = new Map()
for (const dayIndex of gridTimeMap.keys()) {
if (!graph.has(dayIndex)) {
graph.set(dayIndex, new Map())
}
for (const [_gridTime, { id: ids }] of gridTimeMap.get(dayIndex)) {
for (const id1 of ids) {
if (!graph.get(dayIndex).has(id1)) {
graph.get(dayIndex).set(id1, new Set())
}
for (const id2 of ids) {
if (id1 !== id2) {
if (!graph.get(dayIndex).get(id1).has(id2)) {
graph.get(dayIndex).get(id1).add(id2)
}
}
}
}
}
}
const dateKey = dayjs(selectedDateRange.value.start).add(dayIndex, 'day').format('YYYY-MM-DD')
for (const hours in overlaps[dateKey]) {
// We are checking if the overlaps object contains the id of the record
// If it does, we set the maxOverlaps and overlapIndex
if (overlaps[dateKey][hours].id.includes(record.rowMeta.id!)) {
maxOverlaps = Math.max(maxOverlaps, overlaps[dateKey][hours].id.length - overlaps[dateKey][hours].overflowCount)
overlapIndex = Math.max(overlapIndex, overlaps[dateKey][hours].id.indexOf(record.rowMeta.id!))
for (const dayIndex in columnArray) {
for (const columnIndex in columnArray[dayIndex]) {
for (const record of columnArray[dayIndex][columnIndex]) {
record.rowMeta.overLapIteration = parseInt(columnIndex) + 1
}
}
const spacing = 0.1
const widthPerRecord = (100 - spacing * (maxOverlaps - 1)) / maxOverlaps / 7
const leftPerRecord = widthPerRecord * overlapIndex
}
for (const record of recordsToDisplay) {
const { maxOverlaps, overlapIndex } = getMaxOverlaps({
row: record,
columnArray,
graph: graph.get(record.rowMeta.dayIndex!) ?? new Map(),
})
const dayIndex = record.rowMeta.dayIndex ?? tDayIndex
record.rowMeta.numberOfOverlaps = maxOverlaps
let width = 0
let left = 100
const majorLeft = dayIndex * perWidth
let display = 'block'
if (record.rowMeta.overLapIteration! - 1 > 2) {
display = 'none'
} else {
width = 100 / Math.min(maxOverlaps, 3) / 7
left = width * (overlapIndex - 1)
}
record.rowMeta.style = {
...record.rowMeta.style,
left: `calc(${dayIndex * perWidth}px + ${leftPerRecord}% )`,
width: `calc(${widthPerRecord - 0.1}%)`,
left: `calc(${majorLeft}px + ${left}%)`,
width: `calc(${width}%)`,
display,
}
}
return record
})
})
return {
records: recordsToDisplay,
count: overlaps,
gridTimeMap,
}
})
@ -497,9 +634,11 @@ const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: R
const calculateNewRow = (
event: MouseEvent,
updateSideBar?: boolean,
skipChangeCheck?: boolean,
): {
newRow: Row | null
updatedProperty: string[]
skipChangeCheck?: boolean
} => {
const { width, left, top } = container.value.getBoundingClientRect()
@ -528,7 +667,7 @@ const calculateNewRow = (
...dragRecord.value,
row: {
...dragRecord.value.row,
[fromCol.title!]: dayjs(newStartDate).format('YYYY-MM-DD HH:mm:ssZ'),
[fromCol.title!]: dayjs(newStartDate).utc().format('YYYY-MM-DD HH:mm:ssZ'),
},
}
@ -546,11 +685,16 @@ const calculateNewRow = (
endDate = newStartDate.clone()
}
newRow.row[toCol.title!] = dayjs(endDate).format('YYYY-MM-DD HH:mm:ssZ')
newRow.row[toCol.title!] = dayjs(endDate).utc().format('YYYY-MM-DD HH:mm:ssZ')
updatedProperty.push(toCol.title!)
}
if (!newRow) return { newRow: null, updatedProperty }
// If from and to columns of the dragRecord and the newRow are the same, we don't manipulate the formattedRecords and formattedSideBarData. This removes unwanted computation
if (dragRecord.value.row[fromCol.title!] === newRow.row[fromCol.title!] && !skipChangeCheck) {
return { newRow: null, updatedProperty: [] }
}
if (!newRow) return { newRow: null, updatedProperty: [] }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -565,6 +709,10 @@ const calculateNewRow = (
const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r
})
dragRecord.value = {
...dragRecord.value,
row: newRow.row,
}
}
return { newRow, updatedProperty }
@ -591,7 +739,7 @@ const stopDrag = (event: MouseEvent) => {
event.preventDefault()
clearTimeout(dragTimeout.value!)
const { newRow, updatedProperty } = calculateNewRow(event, false)
const { newRow, updatedProperty } = calculateNewRow(event, false, true)
// We set the visibility and opacity of the records back to normal
const allRecords = document.querySelectorAll('.draggable-record')
@ -676,33 +824,19 @@ const viewMore = (hour: dayjs.Dayjs) => {
}
const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => {
let startOfHour = hour.startOf('hour')
const endOfHour = hour.endOf('hour')
const ids: Array<string> = []
let isOverflow = false
if (!recordsAcrossAllRange.value || !recordsAcrossAllRange.value.gridTimeMap) return { isOverflow: false, overflowCount: 0 }
const { gridTimeMap } = recordsAcrossAllRange.value
const dayIndex = getDayIndex(hour)
const startMinute = hour.hour() * 60 + hour.minute()
const endMinute = hour.hour() * 60 + hour.minute() + 59
let overflowCount = 0
while (startOfHour.isBefore(endOfHour, 'minute')) {
const dateKey = startOfHour.format('YYYY-MM-DD')
const hourKey = startOfHour.format('HH:mm')
if (recordsAcrossAllRange.value?.count?.[dateKey]?.[hourKey]?.overflow) {
isOverflow = true
recordsAcrossAllRange.value?.count?.[dateKey]?.[hourKey]?.id.forEach((id) => {
if (!ids.includes(id)) {
ids.push(id)
overflowCount += 1
}
})
}
startOfHour = startOfHour.add(1, 'minute')
for (let minute = startMinute; minute <= endMinute; minute++) {
const recordCount = gridTimeMap.get(dayIndex)?.get(minute)?.count ?? 0
overflowCount = Math.max(overflowCount, recordCount)
}
overflowCount = overflowCount > 4 ? overflowCount - 4 : 0
return { isOverflow, overflowCount }
return { isOverflow: overflowCount - 3 > 0, overflowCount: overflowCount - 3 }
}
// TODO: Add Support for multiple ranges when multiple ranges are supported
@ -773,7 +907,6 @@ watch(
@click="
() => {
selectedTime = hour
selectedDate = hour
dragRecord = undefined
}
"

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

@ -261,8 +261,8 @@ const onClickAudit = () => {
{{ log.description.substring(log.description.indexOf(':') + 1) }}
</div>
<div v-if="log.id === editLog?.id" class="flex justify-end gap-1">
<NcButton type="secondary" size="sm" @click="onCancel"> Cancel </NcButton>
<NcButton v-e="['a:row-expand:comment:save']" size="sm" @click="onEditComment"> Save </NcButton>
<NcButton size="small" type="secondary" @click="onCancel"> Cancel </NcButton>
<NcButton v-e="['a:row-expand:comment:save']" size="small" @click="onEditComment"> Save </NcButton>
</div>
</div>
</div>

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

@ -51,6 +51,7 @@ interface Props {
lastRow?: boolean
closeAfterSave?: boolean
newRecordHeader?: string
skipReload?: boolean
}
const props = defineProps<Props>()
@ -102,7 +103,7 @@ const expandedFormScrollWrapper = ref()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const reloadViewDataTrigger = inject(ReloadViewDataHookInj)
const reloadViewDataTrigger = inject(ReloadViewDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
@ -137,6 +138,8 @@ provide(MetaInj, meta)
const isLoading = ref(true)
const isSaving = ref(false)
const {
commentsDrawer,
changedColumns,
@ -207,26 +210,31 @@ const onDuplicateRow = () => {
}
const save = async () => {
isSaving.value = true
let kanbanClbk
if (activeView.value?.type === ViewTypes.KANBAN) {
kanbanClbk = (row: any, isNewRow: boolean) => {
addOrEditStackRow(row, isNewRow)
}
}
if (isNew.value) {
await _save(rowState.value, undefined, {
kanbanClbk,
})
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
} else {
await _save(undefined, undefined, {
kanbanClbk,
})
_loadRow()
}
if (!props.skipReload) {
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
}
isUnsavedFormExist.value = false
if (props.closeAfterSave) {
@ -234,6 +242,8 @@ const save = async () => {
}
emits('createdRecord', _row.value.row)
isSaving.value = false
}
const isPreventChangeModalOpen = ref(false)
@ -871,6 +881,7 @@ export default {
<NcButton
v-e="['c:row-expand:save']"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist"
:loading="isSaving"
class="nc-expand-form-save-btn !xs:(text-base)"
data-testid="nc-expanded-form-save"
type="primary"
@ -917,7 +928,7 @@ export default {
<div class="flex flex-row justify-end gap-x-2 mt-5">
<NcButton type="secondary" @click="discardPreventModal">{{ $t('labels.discard') }}</NcButton>
<NcButton key="submit" type="primary" label="Rename Table" loading-label="Renaming Table" @click="saveChanges">
<NcButton key="submit" type="primary" :loading="isSaving" @click="saveChanges">
{{ $t('tooltip.saveChanges') }}
</NcButton>
</div>

40
packages/nc-gui/components/smartsheet/grid/Table.vue

@ -60,7 +60,7 @@ import type { CellRange, Row } from '#imports'
const props = defineProps<{
data: Row[]
paginationData?: PaginatedType
loadData?: (params?: any) => Promise<void>
loadData?: (params?: any, shouldShowLoading?: boolean) => Promise<void>
changePage?: (page: number) => void
callAddEmptyRow?: (addAfter?: number) => Row | undefined
deleteRow?: (rowIndex: number, undo?: boolean) => Promise<void>
@ -1064,13 +1064,27 @@ async function resetAndChangePage(row: number, col: number, pageChange?: number)
scrollToCell?.()
}
const saveOrUpdateRecords = async (args: { metaValue?: TableType; viewMetaValue?: ViewType; data?: any } = {}) => {
const temporaryNewRowStore = ref<Row[]>([])
const saveOrUpdateRecords = async (
args: { metaValue?: TableType; viewMetaValue?: ViewType; data?: any; keepNewRecords?: boolean } = {},
) => {
for (const currentRow of args.data || dataRef.value) {
if (currentRow.rowMeta.fromExpandedForm) continue
/** if new record save row and save the LTAR cells */
if (currentRow.rowMeta.new) {
const savedRow = await updateOrSaveRow?.(currentRow, '', {}, args)
await syncLTARRefs?.(currentRow, savedRow, args)
const beforeSave = clone(currentRow)
const savedRow = await updateOrSaveRow?.(currentRow, '', currentRow.rowMeta.ltarState || {}, args)
if (savedRow) {
currentRow.rowMeta.changed = false
} else {
if (args.keepNewRecords) {
if (beforeSave.rowMeta.new && Object.keys(beforeSave.row).length) {
temporaryNewRowStore.value.push(beforeSave)
}
}
}
continue
}
@ -1277,7 +1291,10 @@ const showFillHandle = computed(
isFormula(fields.value[activeCell.col]) ||
isCreatedOrLastModifiedTimeCol(fields.value[activeCell.col]) ||
isCreatedOrLastModifiedByCol(fields.value[activeCell.col])
),
) &&
!isViewDataLoading.value &&
!isPaginationLoading.value &&
dataRef.value.length,
)
watch(
@ -1322,16 +1339,23 @@ eventBus.on(async (event, payload) => {
})
async function reloadViewDataHandler(params: void | { shouldShowLoading?: boolean | undefined; offset?: number | undefined }) {
isViewDataLoading.value = true
if (params?.shouldShowLoading) isViewDataLoading.value = true
if (predictedNextColumn.value?.length) {
const fieldsAvailable = meta.value?.columns?.map((c) => c.title)
predictedNextColumn.value = predictedNextColumn.value.filter((c) => !fieldsAvailable?.includes(c.title))
}
// save any unsaved data before reload
await saveOrUpdateRecords()
await saveOrUpdateRecords({
keepNewRecords: true,
})
await loadData?.({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) }, params?.shouldShowLoading)
await loadData?.({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) })
if (temporaryNewRowStore.value.length) {
dataRef.value.push(...temporaryNewRowStore.value)
temporaryNewRowStore.value = []
}
calculateSlices()

30
packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts

@ -9,7 +9,7 @@ export const useColumnDrag = ({
tableBodyEl: Ref<HTMLElement | undefined>
gridWrapper: Ref<HTMLElement | undefined>
}) => {
const { eventBus } = useSmartsheetStoreOrThrow()
const { eventBus, isDefaultView, meta } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
const { activeView } = storeToRefs(useViewsStore())
@ -22,6 +22,24 @@ export const useColumnDrag = ({
const dragColPlaceholderDomRef = ref<HTMLElement | null>(null)
const toBeDroppedColId = ref<string | null>(null)
const updateDefaultViewColumnOrder = (columnId: string, order: number) => {
if (!meta.value?.columns) return
const colIndex = meta.value.columns.findIndex((c) => c.id === columnId)
if (colIndex !== -1) {
meta.value.columns[colIndex].meta = { ...(meta.value.columns[colIndex].meta || {}), defaultViewColOrder: order }
meta.value.columns = (meta.value.columns || []).map((c) => {
if (c.id !== columnId) return c
c.meta = { ...(c.meta || {}), defaultViewColOrder: order }
return c
})
}
if (meta.value.columnsById[columnId]) {
meta.value.columnsById[columnId].meta = { ...(meta.value.columnsById[columnId] || {}), defaultViewColOrder: order }
}
}
const reorderColumn = async (colId: string, toColId: string) => {
const toBeReorderedViewCol = gridViewCols.value[colId]
@ -46,12 +64,19 @@ export const useColumnDrag = ({
toBeReorderedViewCol.order = newOrder
if (isDefaultView.value && toBeReorderedViewCol.fk_column_id) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, newOrder)
}
addUndo({
undo: {
fn: async () => {
if (!fields.value) return
toBeReorderedViewCol.order = oldOrder
if (isDefaultView.value) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, oldOrder)
}
await updateGridViewColumn(colId, { order: oldOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
@ -63,6 +88,9 @@ export const useColumnDrag = ({
if (!fields.value) return
toBeReorderedViewCol.order = newOrder
if (isDefaultView.value) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, newOrder)
}
await updateGridViewColumn(colId, { order: newOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)

2
packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue

@ -12,8 +12,6 @@ const emits = defineEmits(['created'])
const { isParentOpen, columns } = toRefs(props)
const activeView = inject(ActiveViewInj, ref())
const meta = inject(MetaInj, ref())
const { showSystemFields, metaColumnById } = useViewColumnsOrThrow()

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

@ -54,7 +54,7 @@ const {
toggleFieldVisibility,
} = useViewColumnsOrThrow()
const { eventBus } = useSmartsheetStoreOrThrow()
const { eventBus, isDefaultView } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
@ -127,7 +127,7 @@ const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } },
fields.value.map(async (field, index) => {
if (field.order !== index + 1) {
field.order = index + 1
await saveOrUpdate(field, index, true)
await saveOrUpdate(field, index, true, !!isDefaultView.value)
}
}),
)

2
packages/nc-gui/components/tabs/Smartsheet.vue

@ -21,6 +21,7 @@ import {
provide,
ref,
toRef,
useExpandedFormDetachedProvider,
useMetas,
useProvideCalendarViewStore,
useProvideKanbanViewStore,
@ -83,6 +84,7 @@ provide(
ReadonlyInj,
computed(() => !isUIAllowed('dataEdit')),
)
useExpandedFormDetachedProvider()
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere)

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

@ -40,6 +40,8 @@ const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
@ -47,8 +49,6 @@ const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, re
await loadRelatedTableMeta()
const addIcon = computed(() => (cellValue?.value ? 'expand' : 'plus'))
const value = computed(() => {
if (cellValue?.value) {
return cellValue?.value
@ -80,17 +80,32 @@ const belongsToColumn = computed(
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch(listItemsDlg, () => {
isOpen.value = listItemsDlg.value
})
// When isOpen is false, ensure the listItemsDlg is also closed.
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
}
},
{ flush: 'post' },
)
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
watch(value, (next) => {
if (next) {
isOpen.value = false
}
})
</script>
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center w-full">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
@ -109,24 +124,26 @@ watch([listItemsDlg], () => {
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
class="flex-none flex group items-center min-w-4"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
icon="plus"
class="flex-none select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/>
</div>
</div>
<template #overlay>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
/>
hide-back-btn
/> </template
></LazyVirtualCellComponentsLinkRecordDropdown>
</div>
</template>

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

@ -37,6 +37,10 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const isOpen = ref(false)
const hideBackBtn = ref(false)
const { isUIAllowed } = useRoles()
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
@ -85,6 +89,31 @@ const hasManyColumn = computed(
const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
hideBackBtn.value = false
}
const onAttachLinkedRecord = () => {
listItemsDlg.value = false
childListDlg.value = true
}
const openChildList = () => {
if (isUnderLookup.value) return
childListDlg.value = true
listItemsDlg.value = false
isOpen.value = true
hideBackBtn.value = false
}
const openListDlg = () => {
if (isUnderLookup.value) return
listItemsDlg.value = true
childListDlg.value = false
isOpen.value = true
hideBackBtn.value = true
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
@ -95,9 +124,25 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
break
}
})
watch([childListDlg, listItemsDlg], () => {
isOpen.value = childListDlg.value || listItemsDlg.value
})
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
childListDlg.value = false
}
},
{ flush: 'post' },
)
</script>
<template>
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center gap-1 w-full chips-wrapper">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
@ -111,9 +156,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
@unlink="unlinkRef(cell.item)"
/>
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">
more...
</span>
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="openChildList"> more... </span>
</template>
</div>
@ -121,27 +164,35 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
<GeneralIcon
icon="expand"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click.stop="childListDlg = true"
@click.stop="openChildList"
/>
<GeneralIcon
v-if="(!readOnly && isUIAllowed('dataEdit')) || isForm"
icon="plus"
class="select-none text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="listItemsDlg = true"
@click.stop="openListDlg"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems v-if="listItemsDlg || childListDlg" v-model="listItemsDlg" :column="hasManyColumn" />
</div>
<template #overlay>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="hasManyColumn"
:hide-back-btn="hideBackBtn"
@attach-linked-record="onAttachLinkedRecord"
/>
<LazyVirtualCellComponentsLinkedItems
v-if="listItemsDlg || childListDlg"
v-if="childListDlg"
v-model="childListDlg"
:cell-value="localCellValue"
:column="hasManyColumn"
@attach-record="onAttachRecord"
/>
</div>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</template>
<style scoped>

62
packages/nc-gui/components/virtual-cell/Links.vue

@ -25,6 +25,10 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const isOpen = ref(false)
const hideBackBtn = ref(false)
const { isUIAllowed } = useRoles()
const { t } = useI18n()
@ -72,12 +76,22 @@ const toatlRecordsLinked = computed(() => {
const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
hideBackBtn.value = false
}
const onAttachLinkedRecord = () => {
listItemsDlg.value = false
childListDlg.value = true
}
const openChildList = () => {
if (isUnderLookup.value) return
childListDlg.value = true
listItemsDlg.value = false
isOpen.value = true
hideBackBtn.value = false
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
@ -85,6 +99,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
case 'Enter':
if (listItemsDlg.value) return
childListDlg.value = true
isOpen.value = true
e.stopPropagation()
break
}
@ -101,30 +116,34 @@ const openListDlg = () => {
if (isUnderLookup.value) return
listItemsDlg.value = true
childListDlg.value = false
isOpen.value = true
hideBackBtn.value = true
}
const plusBtnRef = ref<HTMLElement | null>(null)
const childListDlgRef = ref<HTMLElement | null>(null)
watch([childListDlg], () => {
if (!childListDlg.value) {
childListDlgRef.value?.focus()
}
watch([childListDlg, listItemsDlg], () => {
isOpen.value = childListDlg.value || listItemsDlg.value
})
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
childListDlg.value = false
}
})
},
{ flush: 'post' },
)
</script>
<template>
<div class="nc-cell-field flex w-full group items-center nc-links-wrapper py-1" @dblclick.stop="openChildList">
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex w-full group items-center">
<div class="block flex-shrink truncate">
<component
:is="isUnderLookup ? 'span' : 'a'"
ref="childListDlgRef"
v-e="['c:cell:links:modal:open']"
:title="textVal"
class="text-center nc-datatype-link underline-transparent"
@ -140,7 +159,6 @@ watch([listItemsDlg], () => {
<div
v-if="!isUnderLookup"
ref="plusBtnRef"
:tabindex="readOnly ? -1 : 0"
class="!xs:hidden flex group justify-end group-hover:flex items-center"
@keydown.enter.stop="openListDlg"
@ -151,19 +169,25 @@ watch([listItemsDlg], () => {
@click.stop="openListDlg"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg || childListDlg"
v-model="listItemsDlg"
:column="relatedTableDisplayColumn"
/>
</div>
<template #overlay>
<LazyVirtualCellComponentsLinkedItems
v-if="listItemsDlg || childListDlg"
v-if="childListDlg"
v-model="childListDlg"
:items="toatlRecordsLinked"
:column="relatedTableDisplayColumn"
:cell-value="localCellValue"
@attach-record="onAttachRecord"
/>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="relatedTableDisplayColumn"
:hide-back-btn="hideBackBtn"
@attach-linked-record="onAttachLinkedRecord"
/>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</div>
</template>

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

@ -38,6 +38,10 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const isOpen = ref(false)
const hideBackBtn = ref(false)
const { isUIAllowed } = useRoles()
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
@ -81,6 +85,31 @@ const unlinkRef = async (rec: Record<string, any>) => {
const onAttachRecord = () => {
childListDlg.value = false
listItemsDlg.value = true
hideBackBtn.value = false
}
const onAttachLinkedRecord = () => {
listItemsDlg.value = false
childListDlg.value = true
}
const openChildList = () => {
if (isUnderLookup.value) return
childListDlg.value = true
listItemsDlg.value = false
isOpen.value = true
hideBackBtn.value = false
}
const openListDlg = () => {
if (isUnderLookup.value) return
listItemsDlg.value = true
childListDlg.value = false
isOpen.value = true
hideBackBtn.value = true
}
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEvent) => {
@ -96,9 +125,25 @@ const m2mColumn = computed(
() =>
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
watch([childListDlg, listItemsDlg], () => {
isOpen.value = childListDlg.value || listItemsDlg.value
})
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
childListDlg.value = false
}
},
{ flush: 'post' },
)
</script>
<template>
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center gap-1 w-full chips-wrapper">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
@ -112,9 +157,7 @@ const m2mColumn = computed(
@unlink="unlinkRef(cell.item)"
/>
<span v-if="cells?.length === 10" class="caption pointer ml-1 grey--text" @click.stop="childListDlg = true">
more...
</span>
<span v-if="cells?.length === 10" class="caption pointer ml-1 grey--text" @click.stop="openChildList"> more... </span>
</template>
</div>
@ -122,27 +165,35 @@ const m2mColumn = computed(
<GeneralIcon
icon="expand"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"
@click.stop="childListDlg = true"
@click.stop="openChildList"
/>
<GeneralIcon
v-if="!readOnly && isUIAllowed('dataEdit')"
icon="plus"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-plus"
@click.stop="listItemsDlg = true"
@click.stop="openListDlg"
/>
</div>
</div>
<LazyVirtualCellComponentsUnLinkedItems v-if="listItemsDlg || childListDlg" v-model="listItemsDlg" :column="m2mColumn" />
<template #overlay>
<LazyVirtualCellComponentsLinkedItems
v-if="listItemsDlg || childListDlg"
v-if="childListDlg"
v-model="childListDlg"
:cell-value="localCellValue"
:column="m2mColumn"
@attach-record="onAttachRecord"
/>
</div>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="m2mColumn"
:hide-back-btn="hideBackBtn"
@attach-linked-record="onAttachLinkedRecord"
/>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</template>
<style scoped>

29
packages/nc-gui/components/virtual-cell/OneToOne.vue

@ -40,6 +40,8 @@ const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
@ -80,16 +82,24 @@ const belongsToColumn = computed(
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
)
const plusBtnRef = ref<HTMLElement | null>(null)
watch(listItemsDlg, () => {
isOpen.value = listItemsDlg.value
})
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
// When isOpen is false, ensure the listItemsDlg is also closed.
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
}
})
},
{ flush: 'post' },
)
</script>
<template>
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
@ -109,7 +119,6 @@ watch([listItemsDlg], () => {
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
ref="plusBtnRef"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
@ -120,14 +129,16 @@ watch([listItemsDlg], () => {
@click.stop="listItemsDlg = true"
/>
</div>
</div>
<template #overlay>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
hide-back-btn
/>
</div>
</template>
</LazyVirtualCellComponentsLinkRecordDropdown>
</template>
<style scoped lang="scss">

2
packages/nc-gui/components/virtual-cell/QrCode.vue

@ -9,7 +9,7 @@ const cellValue = inject(CellValueInj)
const isGallery = inject(IsGalleryInj, ref(false))
const qrValue = computed(() => String(cellValue?.value))
const qrValue = computed(() => String(cellValue?.value || ''))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))

79
packages/nc-gui/components/virtual-cell/components/Header.vue

@ -1,20 +1,22 @@
<script lang="ts" setup>
import OnetoOneIcon from '~icons/nc-icons/onetoone'
import InfoIcon from '~icons/nc-icons/info'
import FileIcon from '~icons/nc-icons/file'
import { iconMap } from '#imports'
const { relation, relatedTableTitle, displayValue, header, tableTitle } = defineProps<{
const {
relation,
relatedTableTitle,
tableTitle,
linkedRecords = 0,
} = defineProps<{
relation: string
header?: string | null
tableTitle: string
relatedTableTitle: string
displayValue?: string
linkedRecords?: number
}>()
const { isMobileMode } = useGlobal()
const { t } = useI18n()
const relationMeta = computed(() => {
@ -52,56 +54,17 @@ const relationMeta = computed(() => {
</script>
<template>
<div class="flex sm:justify-between relative pb-2 items-center">
<div v-if="!isMobileMode" class="flex text-base font-bold justify-start items-center min-w-36">
{{ header ?? '' }}
</div>
<div class="flex flex-row sm:w-[calc(100%-16rem)] xs:w-full items-center justify-center gap-2 xs:(h-full)">
<div class="flex sm:justify-end w-[calc(50%-1.5rem)] xs:(w-[calc(50%-1.5rem)] h-full)">
<div
class="flex max-w-full xs:w-full flex-shrink-0 xs:(h-full) rounded-md gap-1 text-gray-700 items-center bg-gray-100 px-2 py-1"
>
<FileIcon class="w-4 h-4 min-w-4" />
<span class="truncate">
{{ displayValue }}
</span>
</div>
</div>
<NcTooltip class="flex-shrink-0">
<template #title> {{ relationMeta.title }} </template>
<component
:is="relationMeta.icon"
class="w-7 h-7 p-1 rounded-md"
:class="{
'!bg-orange-500': relation === 'hm',
'!bg-pink-500': relation === 'mm',
'!bg-blue-500': relation === 'bt',
}"
/>
</NcTooltip>
<div class="flex justify-start xs:w-[calc(50%-1.5rem)] w-[calc(50%-1.5rem)] xs:justify-start">
<div
class="flex rounded-md max-w-full flex-shrink-0 gap-1 items-center px-2 py-1 xs:w-full overflow-hidden"
class="flex-none flex rounded-md gap-1 items-center p-1 max-h-7"
:class="{
'!bg-orange-50 !text-orange-500': relation === 'hm',
'!bg-pink-50 !text-pink-500': relation === 'mm',
'!bg-blue-50 !text-blue-500': relation === 'bt',
'bg-gray-200 text-gray-600': !linkedRecords,
'bg-orange-100 text-orange-700': relation === 'hm' && linkedRecords,
'bg-pink-100 text-pink-700': relation === 'mm' && linkedRecords,
'bg-blue-100 text-blue-700': relation === 'bt' && linkedRecords,
'bg-purple-100 text-purple-700': relation === 'oo' && linkedRecords,
}"
>
<MdiFileDocumentMultipleOutline
class="w-4 h-4 min-w-4"
:class="{
'!text-orange-500': relation === 'hm',
'!text-pink-500': relation === 'mm',
'!text-blue-500': relation === 'bt',
}"
/>
<span class="truncate"> {{ relatedTableTitle }} Records </span>
</div>
</div>
</div>
<div v-if="!isMobileMode" class="flex flex-row justify-end w-36">
<NcTooltip class="z-10" placement="bottom">
<NcTooltip class="z-10 flex" placement="bottom">
<template #title>
<div class="p-1">
<h1 class="text-white font-bold">{{ relationMeta.title }}</h1>
@ -117,8 +80,20 @@ const relationMeta = computed(() => {
</div>
</div>
</template>
<InfoIcon class="w-4 h-4" />
<component
:is="relationMeta.icon"
class="flex-none w-5 h-5 p-1 rounded-md"
:class="{
'!bg-orange-500': relation === 'hm',
'!bg-pink-500': relation === 'mm',
'!bg-blue-500': relation === 'bt',
}"
/>
</NcTooltip>
<div class="leading-[20px]">
{{ linkedRecords || 0 }} {{ $t('general.linked') }}
{{ linkedRecords === 1 ? $t('objects.record') : $t('objects.records') }}
</div>
</div>
</template>

85
packages/nc-gui/components/virtual-cell/components/LinkRecordDropdown.vue

@ -0,0 +1,85 @@
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
isOpen: boolean
}
const props = withDefaults(defineProps<Props>(), {
isOpen: false,
})
const emits = defineEmits(['update:isOpen'])
const isOpen = useVModel(props, 'isOpen', emits)
const ncLinksDropdownRef = ref<HTMLDivElement>()
const randomClass = `link-records_${Math.floor(Math.random() * 99999)}`
const addOrRemoveClass = (add: boolean = false) => {
const dropdownRoot = ncLinksDropdownRef.value?.parentElement?.parentElement?.parentElement?.parentElement as HTMLElement
if (dropdownRoot) {
if (add) {
dropdownRoot.classList.add('inset-0', 'nc-link-dropdown-root', `nc-root-${randomClass}`)
} else {
dropdownRoot.classList.remove('inset-0', 'nc-link-dropdown-root', `nc-root-${randomClass}`)
}
}
}
watch(
isOpen,
(next) => {
if (next) {
onClickOutside(document.querySelector(`.${randomClass}`)! as HTMLDivElement, (e) => {
const targetEl = e?.target as HTMLElement
if (!targetEl?.classList.contains(`nc-root-${randomClass}`) || targetEl?.closest(`.nc-${randomClass}`)) {
return
}
isOpen.value = false
addOrRemoveClass(false)
})
} else {
addOrRemoveClass(false)
}
},
{ flush: 'post' },
)
watch([ncLinksDropdownRef, isOpen], () => {
if (!ncLinksDropdownRef.value) return
if (isOpen.value) {
addOrRemoveClass(true)
} else {
addOrRemoveClass(false)
}
})
</script>
<template>
<NcDropdown
:visible="isOpen"
placement="bottom"
overlay-class-name="nc-links-dropdown !min-w-[540px]"
:class="`.nc-${randomClass}`"
>
<slot />
<template #overlay>
<div ref="ncLinksDropdownRef" class="h-[412px] w-[540px]" :class="`${randomClass}`">
<slot name="overlay" />
</div>
</template>
</NcDropdown>
</template>
<style lang="scss">
.nc-links-dropdown {
z-index: 1000 !important;
}
.nc-link-dropdown-root {
z-index: 1000;
}
</style>

284
packages/nc-gui/components/virtual-cell/components/LinkedItems.vue

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { type ColumnType, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import {
ColumnInj,
IsFormInj,
@ -30,10 +31,14 @@ const vModel = useVModel(props, 'modelValue', emit)
const { isMobileMode } = useGlobal()
const { t } = useI18n()
const isForm = inject(IsFormInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isExpandedFormCloseAfterSave = ref(false)
const injectedColumn = inject(ColumnInj, ref())
const readOnly = inject(ReadonlyInj, ref(false))
@ -58,7 +63,7 @@ const {
relatedTableMeta,
link,
meta,
headerDisplayValue,
row,
resetChildrenListOffsetCount,
} = useLTARStoreOrThrow()
@ -68,7 +73,7 @@ watch(
[vModel, isForm],
(nextVal) => {
if ((nextVal[0] || nextVal[1]) && !isNew.value) {
loadChildrenList()
loadChildrenList(true)
}
// reset offset count when closing modal
@ -102,20 +107,96 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col))
.slice(0, isMobileMode.value ? 1 : 4)
.sort((a, b) => {
if (a.meta?.defaultViewColOrder !== undefined && b.meta?.defaultViewColOrder !== undefined) {
return a.meta.defaultViewColOrder - b.meta.defaultViewColOrder
}
return 0
})
.slice(0, isMobileMode.value ? 1 : 3)
})
const expandedFormDlg = ref(false)
const expandedFormRow = ref({})
/** populate initial state for a new row which is parent/child of current record */
const newRowState = computed(() => {
if (isNew.value) return {}
const colOpt = (injectedColumn?.value as ColumnType)?.colOptions as LinkToAnotherRecordType
const colInRelatedTable: ColumnType | undefined = relatedTableMeta?.value?.columns?.find((col) => {
// Links as for the case of 'mm' we need the 'Links' column
if (!isLinksOrLTAR(col)) return false
const colOpt1 = col?.colOptions as LinkToAnotherRecordType
if (colOpt1?.fk_related_model_id !== meta.value.id) return false
if (colOpt.type === RelationTypes.MANY_TO_MANY && colOpt1?.type === RelationTypes.MANY_TO_MANY) {
return (
colOpt.fk_parent_column_id === colOpt1.fk_child_column_id && colOpt.fk_child_column_id === colOpt1.fk_parent_column_id
)
} else {
return (
colOpt.fk_parent_column_id === colOpt1.fk_parent_column_id && colOpt.fk_child_column_id === colOpt1.fk_child_column_id
)
}
})
if (!colInRelatedTable) return {}
const relatedTableColOpt = colInRelatedTable?.colOptions as LinkToAnotherRecordType
if (!relatedTableColOpt) return {}
if (relatedTableColOpt.type === RelationTypes.BELONGS_TO) {
return {
[colInRelatedTable.title as string]: row?.value?.row,
}
} else {
return {
[colInRelatedTable.title as string]: row?.value && [row.value.row],
}
}
})
const colTitle = computed(() => injectedColumn.value?.title || '')
const onClick = (row: Row) => {
if (readOnly.value) return
if (readOnly.value || isForm.value) return
expandedFormRow.value = row
expandedFormDlg.value = true
}
const addNewRecord = () => {
expandedFormRow.value = {}
expandedFormDlg.value = true
isExpandedFormCloseAfterSave.value = true
}
const onCreatedRecord = (record: any) => {
const msgVNode = h(
'div',
{
class: 'ml-1 inline-flex flex-col gap-1 items-start',
},
[
h(
'span',
{
class: 'font-semibold',
},
t('activity.recordCreatedLinked'),
),
h(
'span',
{
class: 'text-gray-500',
},
t('activity.gotSavedLinkedSuccessfully', {
tableName: relatedTableMeta.value?.title,
recordTitle: record[relatedTableDisplayValueProp.value],
}),
),
],
)
message.success(msgVNode)
}
const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type
@ -129,6 +210,9 @@ watch(
)
watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
}
childrenExcludedOffsetCount.value = 0
childrenListOffsetCount.value = 0
})
@ -154,6 +238,10 @@ const skeletonCount = computed(() => {
})
const totalItemsToShow = computed(() => {
if (isForm.value || isNew.value) {
return state.value?.[colTitle.value]?.length
}
if (isChildrenLoading.value) {
return props.items
}
@ -204,6 +292,10 @@ const linkedShortcuts = (e: KeyboardEvent) => {
onMounted(() => {
window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
})
const childrenListRef = ref<HTMLDivElement>()
@ -226,34 +318,17 @@ const onFilterChange = () => {
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '640px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="false"
:footer="null"
:width="isForm ? 600 : 800"
size="medium"
wrap-class-name="nc-modal-child-list"
>
<LazyVirtualCellComponentsHeader
v-if="!isForm"
:display-value="headerDisplayValue"
:header="$t('activity.linkedRecords')"
:linked-records="childrenListCount"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
<div v-if="!isForm" class="flex mt-2 mb-2 items-center gap-2">
<div class="flex items-center border-1 p-1 rounded-md w-full border-gray-200 !focus-within:border-primary">
<MdiMagnify class="w-5 h-5 ml-2 text-gray-500" />
<div class="nc-modal-child-list h-full w-full" :class="{ active: vModel }" @keydown.enter.stop>
<div class="flex flex-col h-full">
<div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
<div v-if="!isForm" class="flex-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
<MdiMagnify class="nc-search-icon w-5 h-5" />
<a-input
ref="filterQueryRef"
v-model:value="childrenListPagination.query"
:bordered="false"
:placeholder="`Search in ${relatedTableMeta?.title}`"
class="w-full !sm:rounded-md xs:min-h-8 !xs:rounded-xl"
placeholder="Search linked records..."
class="w-full min-h-4"
size="small"
@change="onFilterChange"
@keydown.capture.stop="
@ -266,35 +341,41 @@ const onFilterChange = () => {
>
</a-input>
</div>
<div v-else>&nbsp;</div>
<LazyVirtualCellComponentsHeader
data-testid="nc-link-count-info"
:linked-records="totalItemsToShow"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
</div>
<div ref="childrenListRef" class="flex flex-col flex-grow nc-scrollbar-md cursor-pointer pr-1">
<div v-if="isDataExist || isChildrenLoading" class="mt-2 mb-2">
<div class="cursor-pointer pr-1">
<div ref="childrenListRef" class="flex-1 overflow-auto nc-scrollbar-thin">
<div v-if="isDataExist || isChildrenLoading">
<div class="cursor-pointer">
<template v-if="isChildrenLoading">
<div
v-for="(_x, i) in Array.from({ length: skeletonCount })"
:key="i"
class="!border-2 flex flex-row gap-2 mb-2 transition-all !rounded-xl relative !border-gray-200 hover:bg-gray-50"
class="flex flex-row gap-2 mb-2 transition-all relative !border-gray-200 hover:bg-gray-50"
>
<a-skeleton-image class="h-24 w-24 !rounded-xl" />
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center">
<a-skeleton-input active class="!w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
<div class="flex items-center">
<a-skeleton-image class="h-14 w-14 !rounded-xl children:!h-full" />
</div>
<div class="flex flex-col gap-2 flex-grow justify-center">
<a-skeleton-input active class="h-3 !w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" />
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
</div>
</div>
@ -312,29 +393,30 @@ const onFilterChange = () => {
:related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow"
data-testid="nc-child-list-item"
@click="linkOrUnLink(refRow, id)"
@link-or-unlink="linkOrUnLink(refRow, id)"
@expand="onClick(refRow)"
@keydown.space.prevent="linkOrUnLink(refRow, id)"
@keydown.enter.prevent="() => onClick(refRow, id)"
@keydown.space.prevent.stop="linkOrUnLink(refRow, id)"
@keydown.enter.prevent.stop="() => onClick(refRow, id)"
/>
</template>
</div>
</div>
<div v-else class="pt-1 flex flex-col gap-4 my-auto items-center justify-center text-gray-500 text-center">
<div v-else class="h-full flex flex-col gap-2 my-auto items-center justify-center text-gray-500 text-center">
<img
:alt="$t('msg.clickLinkRecordsToAddLinkFromTable', { tableName: relatedTableMeta?.title })"
class="!w-[18.5rem] flex-none"
:alt="$t('msg.clickLinkRecordsToAddLinkFromTable')"
class="!w-[158px] flex-none"
src="~assets/img/placeholder/link-records.png"
/>
<div class="text-2xl text-gray-700 font-bold">{{ $t('msg.noLinkedRecords') }}</div>
<div class="text-base text-gray-700 font-bold">{{ $t('msg.noLinkedRecords') }}</div>
<div class="text-gray-700">
{{ $t('msg.clickLinkRecordsToAddLinkFromTable', { tableName: relatedTableMeta?.title }) }}
{{ $t('msg.clickLinkRecordsToAddLinkFromTable') }}
</div>
<NcButton
v-if="!readOnly && childrenListCount < 1"
v-if="!readOnly && (childrenListCount < 1 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to"
size="small"
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1"><MdiPlus /> {{ $t('title.linkRecords') }}</div>
@ -342,51 +424,45 @@ const onFilterChange = () => {
</div>
</div>
<div v-if="isMobileMode" class="flex flex-row justify-center items-center w-full my-2">
<NcPagination
v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
/>
<div class="bg-gray-100 px-3 py-2 rounded-b-md flex items-center justify-between gap-3 min-h-12">
<div class="flex items-center gap-2">
<NcButton
v-if="!isPublic"
v-e="['c:row-expand:open']"
size="small"
class="!hover:(bg-white text-brand-500)"
type="secondary"
@click="addNewRecord"
>
<div class="flex items-center gap-1">
<MdiPlus v-if="!isMobileMode" class="h-4 w-4" /> {{ $t('activity.newRecord') }}
</div>
<div class="my-2 bg-gray-50 border-gray-50 border-b-2"></div>
<div class="flex flex-row justify-between bg-white relative pt-1">
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50">
{{ totalItemsToShow || 0 }} {{ !isMobileMode ? $t('objects.records') : '' }}
{{ !isMobileMode && totalItemsToShow !== 0 ? $t('general.are') : '' }}
{{ $t('general.linked') }}
</NcButton>
<NcButton
v-if="!readOnly && (childrenListCount > 0 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to"
class="!hover:(bg-white text-brand-500)"
size="small"
type="secondary"
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1">
<GeneralIcon icon="link2" class="!xs:hidden h-4 w-4" />
{{ isMobileMode ? $t('title.linkMore') : $t('title.linkMoreRecords') }}
</div>
<div v-else class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50">
<span class="">
{{ state?.[colTitle]?.length || 0 }} {{ $t('objects.records') }}
{{ state?.[colTitle]?.length !== 0 ? $t('general.are') : '' }}
{{ $t('general.linked') }}
</span>
</NcButton>
</div>
<div class="!xs:hidden flex absolute -mt-0.75 items-center py-2 justify-center w-full">
<template v-if="!isNew && childrenList?.pageInfo && +childrenList.pageInfo.totalRows! > childrenListPagination.size">
<div class="flex justify-center items-center">
<NcPagination
v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!"
mode="simple"
/>
</div>
<div class="flex flex-row gap-2">
<NcButton v-if="!isForm" class="nc-close-btn" type="ghost" @click="vModel = false"> {{ $t('general.finish') }} </NcButton>
<NcButton
v-if="!readOnly && childrenListCount > 0"
v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to"
@click="emit('attachRecord')"
>
<div class="flex items-center gap-1">
<MdiPlus class="!xs:hidden" /> {{ isMobileMode ? $t('title.linkMore') : $t('title.linkMoreRecords') }}
</div>
</NcButton>
</template>
</div>
</div>
@ -394,7 +470,15 @@ const onFilterChange = () => {
<LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg"
:close-after-save="isExpandedFormCloseAfterSave"
:meta="relatedTableMeta"
:new-record-header="
isExpandedFormCloseAfterSave
? $t('activity.tableNameCreateNewRecord', {
tableName: relatedTableMeta?.title,
})
: undefined
"
:row="{
row: expandedFormRow,
oldRow: expandedFormRow,
@ -405,11 +489,13 @@ const onFilterChange = () => {
new: true,
},
}"
:state="newRowState"
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
use-meta-fields
@created-record="onCreatedRecord"
/>
</Suspense>
</NcModal>
</div>
</template>
<style lang="scss" scoped>
@ -420,10 +506,22 @@ const onFilterChange = () => {
:deep(.ant-modal-content) {
@apply !p-0;
}
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style>
<style lang="scss">
.nc-modal-child-list > .ant-modal > .ant-modal-content {
@apply !p-0;
.nc-dropdown-link-record-search-wrapper {
.nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
}
</style>

199
packages/nc-gui/components/virtual-cell/components/ListItem.vue

@ -16,9 +16,9 @@ import {
useVModel,
} from '#imports'
import MaximizeIcon from '~icons/nc-icons/maximize'
import LinkIcon from '~icons/nc-icons/link'
const props = defineProps<{
const props = withDefaults(
defineProps<{
row: any
fields: any[]
attachment: any
@ -26,9 +26,13 @@ const props = defineProps<{
displayValueTypeAndFormatProp: { type: string; format: string }
isLoading: boolean
isLinked: boolean
}>()
}>(),
{
isLoading: false,
},
)
defineEmits(['expand'])
defineEmits(['expand', 'linkOrUnlink'])
provide(IsExpandedFormOpenInj, ref(true))
@ -88,116 +92,198 @@ const displayValue = computed(() => {
</script>
<template>
<div class="nc-list-item-wrapper group px-[1px] hover:bg-gray-50 border-y-1 border-gray-200 border-t-transparent">
<a-card
tabindex="0"
class="nc-list-item !outline-brand-500 !border-1 group transition-all !rounded-xl relative !mb-2 !border-gray-200 hover:bg-gray-50"
class="nc-list-item !outline-none transition-all relative group-hover:bg-gray-50 cursor-auto"
:class="{
'!bg-white': isLoading,
'!border-1': isLinked && !isLoading,
'!cursor-auto !hover:bg-white': readOnly,
'!hover:bg-white': readOnly,
}"
:body-style="{ padding: 0 }"
:body-style="{ padding: '6px 10px !important', borderRadius: 0 }"
:hoverable="false"
>
<div class="flex flex-row items-center justify-start w-full">
<a-carousel v-if="attachment && attachments && attachments.length" autoplay class="!w-24 !h-24 !max-h-24 !max-w-24">
<div class="flex items-center gap-3">
<div v-if="isLoading" class="flex">
<MdiLoading class="flex-none w-7 h-7 !text-brand-500 animate-spin" />
</div>
<NcTooltip v-else class="z-10 flex">
<template #title> {{ isLinked ? 'Unlink' : 'Link' }}</template>
<button
tabindex="-1"
class="nc-list-item-link-unlink-btn p-1.5 flex rounded-lg transition-all"
:class="{
'bg-red-100 text-red-500 hover:bg-red-200': isLinked,
'bg-green-100 text-green-500 hover:bg-green-200': !isLinked,
}"
@click="$emit('linkOrUnlink')"
>
<GeneralIcon :icon="isLinked ? 'minus' : 'plus'" class="flex-none w-4 h-4 !font-extrabold" />
</button>
</NcTooltip>
<template v-if="attachment">
<div v-if="attachments && attachments.length">
<a-carousel autoplay class="!w-11 !h-11 !max-h-11 !max-w-11">
<template #customPaging> </template>
<template v-for="(attachmentObj, index) in attachments">
<LazyCellAttachmentImage
v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)"
:key="`carousel-${attachmentObj.title}-${index}`"
class="!h-24 !w-24 !max-h-24 !max-w-24 object-cover !rounded-l-xl"
class="!w-11 !h-11 !max-h-11 !max-w-11object-cover !rounded-l-xl"
:srcs="getPossibleAttachmentSrc(attachmentObj)"
/>
</template>
</a-carousel>
</div>
<div
v-else-if="attachment"
class="h-24 w-24 !min-h-24 !min-w-24 !max-h-24 !max-w-24 !flex flex-row items-center !rounded-l-xl justify-center"
v-else
class="h-11 w-11 !min-h-11 !min-w-11 !max-h-11 !max-w-11 !flex flex-row items-center !rounded-l-xl justify-center"
>
<GeneralIcon class="w-full h-full !text-6xl !leading-10 !text-transparent rounded-lg" icon="fileImage" />
</div>
</template>
<div class="flex flex-col m-[.75rem] gap-1 flex-grow justify-center overflow-hidden">
<div class="flex justify-between xs:gap-x-2">
<span class="font-semibold text-brand-500 nc-display-value xs:(truncate)">
<div class="flex-1 flex flex-col gap-1 justify-center overflow-hidden">
<div class="flex justify-start">
<span class="font-semibold text-brand-500 nc-display-value truncate leading-[20px]">
{{ displayValue }}
</span>
<div
v-if="isLinked && !isLoading"
class="text-brand-500 text-0.875"
:class="{
'!group-hover:mr-12': fields.length === 0 && !readOnly,
}"
>
<LinkIcon class="w-4 h-4" />
Linked
</div>
<MdiLoading
v-else-if="isLoading"
:class="{
'!group-hover:mr-8': fields.length === 0 && !readOnly,
}"
class="w-6 h-6 !text-brand-500 animate-spin"
/>
</div>
<div
v-if="fields.length > 0 && !isPublic && !isForm"
class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 w-10/12"
class="flex ml-[-0.25rem] sm:flex-row xs:(flex-col mt-2) gap-4 min-h-5"
>
<div v-for="field in fields" :key="field.id" :class="attachment ? 'sm:w-1/3' : 'sm:w-1/4'">
<div class="flex flex-col gap-[-1] max-w-72">
<div v-for="field in fields" :key="field.id" class="sm:(w-1/3 max-w-1/3 overflow-hidden)">
<div v-if="!isRowEmpty(row, field)" class="flex flex-col gap-[-1]">
<NcTooltip class="z-10 flex" placement="bottom">
<template #title>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
class="!scale-60"
class="!scale-60 text-gray-100 !text-sm"
:column="field"
:hide-menu="true"
:hide-icon="true"
/>
<LazySmartsheetHeaderCell v-else class="!scale-70" :column="field" :hide-menu="true" :hide-icon="true" />
<div v-if="!isRowEmpty(row, field)">
<LazySmartsheetHeaderCell v-else class="!scale-70 text-gray-100 !text-sm" :column="field" :hide-menu="true" />
</template>
<div class="nc-link-record-cell flex w-full max-w-full">
<LazySmartsheetVirtualCell v-if="isVirtualCol(field)" v-model="row[field.title]" :row="row" :column="field" />
<LazySmartsheetCell
v-else
v-model="row[field.title]"
class="!text-gray-600 ml-1"
:column="field"
:edit-enabled="false"
:read-only="true"
/>
</div>
<div v-else class="flex flex-row w-full h-[1.375rem] pl-1 items-center justify-start">-</div>
</div>
</NcTooltip>
</div>
<div v-else class="flex flex-row w-full max-w-72 h-5 pl-1 items-center justify-start">-</div>
</div>
</div>
</div>
<NcButton
v-if="!isForm && !isPublic && !readOnly"
<div v-if="!isForm && !isPublic && !readOnly" class="flex-none flex items-center w-7">
<button
v-e="['c:row-expand:open']"
type="text"
size="medium"
class="!px-2 nc-expand-item !group-hover:block !hidden !border-1 !shadow-sm !border-gray-200 !bg-white !absolute right-3 bottom-3"
:class="{
'!group-hover:right-1.8 !group-hover:bottom-1.7': fields.length === 0,
}"
:tabindex="-1"
class="z-10 flex items-center justify-center nc-expand-item !group-hover:visible !invisible !h-7 !w-7 transition-all !hover:children:(w-4.5 h-4.5)"
@click.stop="$emit('expand', row)"
>
<MaximizeIcon class="w-4 h-4" />
</NcButton>
<MaximizeIcon class="flex-none w-4 h-4 scale-125" />
</button>
</div>
</div>
</a-card>
</div>
</template>
<style lang="scss" scoped>
:deep(.slick-list) {
@apply rounded-lg;
}
.nc-list-item-link-unlink-btn {
box-shadow: 0px 3px 1px -2px rgba(0, 0, 0, 0.06), 0px 5px 3px -2px rgba(0, 0, 0, 0.02);
}
.nc-link-record-cell {
:deep(.nc-cell),
:deep(.nc-virtual-cell) {
@apply !text-small !text-gray-600 ml-1;
.nc-cell-field,
.nc-cell-field-link,
input,
textarea {
@apply !text-small !p-0 m-0;
}
&:not(.nc-display-value-cell) {
@apply text-gray-600;
font-weight: 500;
.nc-cell-field,
input,
textarea {
@apply text-gray-600;
font-weight: 500;
}
}
.nc-cell-field,
a.nc-cell-field-link,
input,
textarea {
@apply !p-0 m-0;
}
&.nc-cell-longtext {
@apply leading-[18px];
textarea {
@apply pr-2;
}
.long-text-wrapper {
@apply !min-h-4;
.nc-rich-text-grid {
@apply pl-0 -ml-1;
}
}
}
.ant-picker-input {
@apply text-small leading-4;
font-weight: 500;
input {
@apply text-small leading-4;
font-weight: 500;
}
}
.ant-select:not(.ant-select-customize-input) {
.ant-select-selector {
@apply !border-none flex-nowrap pr-4.5;
}
.ant-select-arrow {
@apply right-[3px];
}
}
}
}
</style>
<style lang="scss">
.nc-list-item {
@apply border-1 border-transparent rounded-md;
&:focus-visible {
@apply border-brand-500;
box-shadow: 0 0 0 1px #3366ff;
}
&:hover {
.nc-text-area-expand-btn {
@apply !hidden;
@ -206,13 +292,14 @@ const displayValue = computed(() => {
.long-text-wrapper {
@apply select-none pointer-events-none;
.nc-readonly-rich-text-wrapper {
@apply !min-h-6 !max-h-6;
@apply !min-h-5 !max-h-5;
}
.nc-rich-text-embed {
@apply -mt-0.5;
.nc-textarea-rich-editor {
@apply !overflow-hidden;
.ProseMirror {
@apply !overflow-hidden line-clamp-1;
@apply !overflow-hidden line-clamp-1 h-[18px] pt-0.4;
}
}
}

201
packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue

@ -14,9 +14,9 @@ import {
useVModel,
} from '#imports'
const props = defineProps<{ modelValue: boolean; column: any }>()
const props = defineProps<{ modelValue: boolean; column: any; hideBackBtn?: boolean }>()
const emit = defineEmits(['update:modelValue', 'addNewRecord'])
const emit = defineEmits(['update:modelValue', 'addNewRecord', 'attachLinkedRecord'])
const vModel = useVModel(props, 'modelValue', emit)
@ -50,7 +50,6 @@ const {
meta,
unlink,
row,
headerDisplayValue,
resetChildrenExcludedOffsetCount,
} = useLTARStoreOrThrow()
@ -66,6 +65,10 @@ const isForm = inject(IsFormInj, ref(false))
const saveRow = inject(SaveRowInj, () => {})
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const reloadViewDataTrigger = inject(ReloadViewDataHookInj, createEventHook())
const linkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) {
addLTARRef(row, injectedColumn?.value as ColumnType)
@ -100,7 +103,7 @@ watch(
if (!isForm.value) {
loadChildrenList()
}
loadChildrenExcludedList(rowState.value)
loadChildrenExcludedList(rowState.value, true)
}
if (!nextVal) {
resetChildrenExcludedOffsetCount()
@ -157,13 +160,31 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col))
.slice(0, isMobileMode.value ? 1 : 4)
.sort((a, b) => {
if (a.meta?.defaultViewColOrder !== undefined && b.meta?.defaultViewColOrder !== undefined) {
return a.meta.defaultViewColOrder - b.meta.defaultViewColOrder
}
return 0
})
.slice(0, isMobileMode.value ? 1 : 3)
})
const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type
})
const totalItemsToShow = computed(() => {
if (relation.value === 'bt') {
return row.value.row[relatedTableMeta.value?.title] ? 1 : 0
}
if (isForm.value || isNew.value) {
return rowState.value?.[injectedColumn!.value?.title]?.length ?? 0
}
return childrenListCount.value ?? 0
})
watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
@ -196,6 +217,15 @@ const addNewRecord = () => {
}
const onCreatedRecord = (record: any) => {
addLTARRef(record, injectedColumn?.value as ColumnType)
reloadTrigger?.trigger({
shouldShowLoading: false,
})
reloadViewDataTrigger?.trigger({
shouldShowLoading: false,
})
const msgVNode = h(
'div',
{
@ -223,6 +253,8 @@ const onCreatedRecord = (record: any) => {
)
message.success(msgVNode)
vModel.value = false
}
const linkedShortcuts = (e: KeyboardEvent) => {
@ -253,6 +285,10 @@ watch(childrenExcludedListPagination, () => {
onMounted(() => {
window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
})
onUnmounted(() => {
@ -268,32 +304,26 @@ const onFilterChange = () => {
</script>
<template>
<NcModal
v-model:visible="vModel"
:body-style="{ 'max-height': '640px', 'height': '85vh' }"
:class="{ active: vModel }"
:closable="false"
:footer="null"
:width="isForm ? 600 : 800"
wrap-class-name="nc-modal-link-record"
<div class="nc-modal-link-record h-full w-full overflow-hidden" :class="{ active: vModel }" @keydown.enter.stop>
<div class="flex flex-col h-full">
<div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
<div class="flex-1 gap-2 flex items-center">
<button
v-if="!hideBackBtn"
class="!text-brand-500 hover:!text-brand-700 p-1.5 flex"
@click="emit('attachLinkedRecord')"
>
<LazyVirtualCellComponentsHeader
v-if="!isForm"
:display-value="headerDisplayValue"
:header="$t('activity.addNewLink')"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
<div class="flex mt-2 mb-2 items-center gap-2">
<div class="flex items-center border-1 p-1 rounded-md w-full border-gray-200 !focus-within:border-primary">
<MdiMagnify class="w-5 h-5 ml-2 text-gray-500" />
<GeneralIcon icon="ncArrowLeft" class="flex-none h-4 w-4" />
</button>
<div class="flex-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
<MdiMagnify class="nc-search-icon w-5 h-5" />
<a-input
ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query"
:bordered="false"
:placeholder="`${$t('general.searchIn')} ${relatedTableMeta?.title}`"
class="w-full !rounded-md nc-excluded-search xs:min-h-8"
placeholder="Search records to link..."
class="w-full nc-excluded-search min-h-4"
size="small"
@change="onFilterChange"
@keydown.capture.stop="
@ -306,49 +336,41 @@ const onFilterChange = () => {
>
</a-input>
</div>
<div class="flex-1" />
<!-- Add new record -->
<NcButton
v-if="!isPublic"
v-e="['c:row-expand:open']"
:size="isMobileMode ? 'medium' : 'small'"
class="!text-brand-500"
type="secondary"
@click="addNewRecord"
>
<div class="flex items-center gap-1 px-4"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div>
</NcButton>
</div>
<LazyVirtualCellComponentsHeader
data-testid="nc-link-count-info"
:linked-records="totalItemsToShow"
:related-table-title="relatedTableMeta?.title"
:relation="relation"
:table-title="meta?.title"
/>
</div>
<div class="flex-1 overflow-auto nc-scrollbar-thin">
<template v-if="childrenExcludedList?.pageInfo?.totalRows">
<div ref="childrenExcludedListRef" class="overflow-scroll nc-scrollbar-md pr-1 cursor-pointer flex flex-col flex-grow">
<div ref="childrenExcludedListRef">
<template v-if="isChildrenExcludedLoading">
<div
v-for="(_x, i) in Array.from({ length: 10 })"
:key="i"
class="!border-2 flex flex-row gap-2 mb-2 transition-all !rounded-xl relative !border-gray-200 hover:bg-gray-50"
class="flex flex-row gap-2 mb-2 transition-all relative !border-gray-200 hover:bg-gray-50"
>
<a-skeleton-image class="h-24 w-24 !rounded-xl" />
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center">
<a-skeleton-input active class="!xs:w-30 !w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
<div class="flex items-center">
<a-skeleton-image class="h-14 w-14 !rounded-xl children:!h-full" />
</div>
<div class="flex flex-col gap-2 flex-grow justify-center">
<a-skeleton-input active class="h-3 !w-48 !rounded-xl" size="small" />
<div class="flex flex-row gap-6 w-10/12">
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
<div class="flex flex-col gap-0.5">
<a-skeleton-input active class="!h-4 !w-12" size="small" />
<a-skeleton-input active class="!xs:hidden !h-4 !w-24" size="small" />
<a-skeleton-input active class="!h-2 !w-12" size="small" />
<a-skeleton-input active class="!h-2 !w-24" size="small" />
</div>
</div>
</div>
@ -366,48 +388,55 @@ const onFilterChange = () => {
:related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow"
data-testid="nc-excluded-list-item"
@click="() => onClick(refRow, id)"
@link-or-unlink="onClick(refRow, id)"
@expand="
() => {
expandedFormRow = refRow
expandedFormDlg = true
}
"
@keydown.space.prevent="() => onClick(refRow, id)"
@keydown.enter.prevent="() => onClick(refRow, id)"
@keydown.space.prevent.stop="() => onClick(refRow, id)"
@keydown.enter.prevent.stop="() => onClick(refRow, id)"
/>
</template>
</div>
</template>
<div v-else class="my-auto py-2 flex flex-col gap-3 items-center justify-center text-gray-500">
<div v-else class="h-full my-auto py-2 flex flex-col gap-3 items-center justify-center text-gray-500">
<InboxIcon class="w-16 h-16 mx-auto" />
<p>
{{ $t('msg.thereAreNoRecordsInTable') }}
{{ relatedTableMeta?.title }}
</p>
</div>
<div v-if="isMobileMode" class="flex flex-row justify-center items-center w-full my-2">
</div>
<div class="bg-gray-100 px-3 py-2 rounded-b-md flex items-center justify-between min-h-12">
<div class="flex">
<NcButton
v-if="!isPublic"
v-e="['c:row-expand:open']"
size="small"
class="!hover:(bg-white text-brand-500)"
type="secondary"
@click="addNewRecord"
>
<div class="flex items-center gap-1"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div>
</NcButton>
</div>
<template
v-if="
childrenExcludedList?.pageInfo && +childrenExcludedList?.pageInfo?.totalRows > childrenExcludedListPagination.size
"
>
<div v-if="isMobileMode" class="flex items-center">
<NcPagination
v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows"
entity-name="links-excluded-list"
/>
</div>
<div class="mb-2 bg-gray-50 border-gray-50 border-b-2"></div>
<div class="flex flex-row justify-between items-center bg-white relative pt-1">
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50 h-9.5">
{{ relation === 'bt' ? (row.row[relatedTableMeta?.title] ? '1' : 0) : childrenListCount ?? 'No' }}
{{ !isMobileMode ? $t('objects.records') : '' }} {{ !isMobileMode && childrenListCount !== 0 ? 'are' : '' }}
{{ $t('general.linked') }}
</div>
<div class="!xs:hidden flex absolute -mt-0.75 items-center py-2 justify-center w-full">
<div v-else class="flex items-center">
<NcPagination
v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows"
@ -415,7 +444,8 @@ const onFilterChange = () => {
mode="simple"
/>
</div>
<NcButton class="nc-close-btn ml-auto" type="ghost" @click="vModel = false"> {{ $t('general.finish') }} </NcButton>
</template>
</div>
</div>
<Suspense>
<LazySmartsheetExpandedForm
@ -443,14 +473,29 @@ const onFilterChange = () => {
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
:state="newRowState"
use-meta-fields
:skip-reload="true"
@created-record="onCreatedRecord"
/>
</Suspense>
</NcModal>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style>
<style lang="scss">
.nc-modal-link-record > .ant-modal > .ant-modal-content {
@apply !p-0;
.nc-dropdown-link-record-search-wrapper {
.nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
}
</style>

162
packages/nc-gui/components/workspace/CollaboratorsList.vue

@ -1,6 +1,10 @@
<script lang="ts" setup>
import { OrderedWorkspaceRoles, WorkspaceUserRoles, parseStringDateTime, timeAgo } from 'nocodb-sdk'
import { storeToRefs, useUserSorts, useWorkspace } from '#imports'
import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { IsAdminPanelInj, storeToRefs, useUserSorts, useWorkspace } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const { workspaceRoles, loadRoles } = useRoles()
@ -8,12 +12,22 @@ const workspaceStore = useWorkspace()
const { removeCollaborator, updateCollaborator: _updateCollaborator } = workspaceStore
const { collaborators, workspaceRole } = storeToRefs(workspaceStore)
const { collaborators, activeWorkspace: _activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const currentWorkspace = computed(() => {
return props.workspaceId ? workspaces.value.get(props.workspaceId) : _activeWorkspace.value
})
const { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Workspace')
const userSearchText = ref('')
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const { isUIAllowed } = useRoles()
const inviteDlg = ref(false)
const filterCollaborators = computed(() => {
if (!userSearchText.value) return collaborators.value ?? []
@ -26,13 +40,34 @@ const filterCollaborators = computed(() => {
)
})
const selected = reactive<{
[key: number]: boolean
}>({})
const toggleSelectAll = (value: boolean) => {
filterCollaborators.value.forEach((_, i) => {
selected[i] = value
})
}
const sortedCollaborators = computed(() => {
return handleGetSortedData(filterCollaborators.value, sorts.value)
})
const selectAll = computed({
get: () =>
Object.values(selected).every((v) => v) &&
Object.keys(selected).length > 0 &&
Object.values(selected).length === sortedCollaborators.value.length,
set: (value) => {
toggleSelectAll(value)
},
})
const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => {
try {
await _updateCollaborator(collab.id, roles)
console.log()
await _updateCollaborator(collab.id, roles, currentWorkspace.value.id)
message.success('Successfully updated user role')
collaborators.value?.forEach((collaborator) => {
@ -54,81 +89,89 @@ const accessibleRoles = computed<WorkspaceUserRoles[]>(() => {
})
onMounted(async () => {
await loadRoles()
await loadRoles(null, {}, currentWorkspace.value?.id)
loadSorts()
})
</script>
<template>
<div class="nc-collaborator-table-container mt-4 mx-6 h-[calc(100vh-12rem)]">
<div class="w-full flex justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<div class="text-xl">Invite Members By Email</div>
<DlgInviteDlg v-model:model-value="inviteDlg" :workspace-id="currentWorkspace.id" type="workspace" />
<div class="nc-collaborator-table-container mt-4 h-[calc(100vh-10rem)]">
<div class="w-full flex justify-between mt-6.5 mb-2">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members">
<template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template>
</a-input>
<NcButton data-testid="nc-add-member-btn" @click="inviteDlg = true">
<div class="flex items-center gap-2">
<component :is="iconMap.plus" class="!h-4 !w-4" />
{{ $t('labels.addMember') }}
</div>
</NcButton>
</div>
<WorkspaceInviteSection v-if="workspaceRole !== WorkspaceUserRoles.VIEWER" />
<div v-if="!filterCollaborators?.length" class="w-full h-full flex flex-col items-center justify-center mt-36">
<a-empty description="No members found" />
</div>
<div v-else class="nc-collaborators-list mt-6 h-full">
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-8rem)]">
<div class="flex flex-row bg-gray-50 min-h-12 items-center">
<div class="text-gray-700 users-email-grid w-3/8 ml-10 mr-3 flex items-center space-x-2">
<div class="flex flex-col rounded-lg overflow-hidden border-1 max-w-350 max-h-[calc(100%-4rem)]">
<div class="flex flex-row bg-gray-50 min-h-11 items-center border-b-1">
<div class="py-3 px-6"><NcCheckbox v-model:checked="selectAll" /></div>
<div class="text-gray-700 w-[30rem] users-email-grid flex items-center space-x-2">
<span>
{{ $t('objects.users') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.email" field="email" :handle-user-sort="saveOrUpdate" />
<LazyAccountUserMenu :direction="sortDirection.email" :handle-user-sort="saveOrUpdate" field="email" />
</div>
<div class="text-gray-700 user-access-grid w-2/8 mr-3 flex items-center space-x-2">
<div class="text-gray-700 w-full flex-1 px-6 py-3 flex items-center space-x-2">
<span>
{{ $t('general.access') }}
</span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div>
<div class="text-gray-700 date-joined-grid w-2/8 mr-3">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 user-access-grid w-1/8">Actions</div>
<div class="text-gray-700 w-full flex-1 px-6 py-3">{{ $t('title.dateJoined') }}</div>
<div class="text-gray-700 w-full text-right flex-1 px-6 py-3">{{ $t('labels.actions') }}</div>
</div>
<div class="flex flex-col nc-scrollbar-md">
<div
v-for="(collab, i) of sortedCollaborators"
:key="i"
class="flex flex-row border-b-1 py-1 min-h-14 items-center justify-around last"
class="user-row flex hover:bg-gray-50 flex-row last:border-b-0 border-b-1 py-1 min-h-14 items-center"
>
<div class="flex gap-3 items-center users-email-grid w-3/8 ml-10">
<GeneralUserIcon size="base" :name="collab.email" :email="collab.email" />
<NcTooltip v-if="collab.display_name">
<template #title>
{{ collab.email }}
</template>
<span class="truncate">
{{ collab.display_name }}
<div class="py-3 px-6">
<NcCheckbox v-model:checked="selected[i]" />
</div>
<div class="flex gap-3 w-[30rem] items-center users-email-grid">
<GeneralUserIcon :email="collab.email" size="base" />
<div class="flex flex-col">
<div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }}
</span>
</NcTooltip>
<span v-else class="truncate">
</div>
<span class="text-xs text-gray-600">
{{ collab.email }}
</span>
</div>
<div class="user-access-grid w-2/8">
<template v-if="accessibleRoles.includes(collab.roles)">
</div>
<div class="w-full flex-1 px-6 py-3">
<div class="w-[30px]">
<template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector
:description="false"
:on-role-change="(role) => updateCollaborator(collab, role)"
:role="collab.roles"
:roles="accessibleRoles"
:description="false"
class="cursor-pointer"
:on-role-change="(role) => updateCollaborator(collab, role)"
/>
</div>
</template>
<template v-else>
<RolesBadge :role="collab.roles" class="cursor-default" />
<RolesBadge :border="false" :role="collab.roles" class="cursor-default" />
</template>
</div>
<div class="date-joined-grid w-2/8 flex justify-start">
</div>
<div class="w-full flex-1 px-6 py-3">
<NcTooltip class="max-w-full">
<template #title>
{{ parseStringDateTime(collab.created_at) }}
@ -138,14 +181,35 @@ onMounted(async () => {
</span>
</NcTooltip>
</div>
<div class="w-1/8 pl-6">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']">
<MdiDotsVertical
class="border-1 !text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
/>
<div class="w-full justify-end flex-1 flex px-6 py-3">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER">
<NcButton size="small" type="secondary">
<component :is="iconMap.threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="removeCollaborator(collab.id)">
<template v-if="isAdminPanel">
<NcMenuItem data-testid="nc-admin-org-user-delete">
<GeneralIcon class="text-gray-800" icon="signout" />
<span>{{ $t('labels.signOutUser') }}</span>
</NcMenuItem>
<a-menu-divider class="my-1.5" />
</template>
<NcMenuItem
v-if="isUIAllowed('transferWorkspaceOwnership')"
data-testid="nc-admin-org-user-assign-admin"
@click="updateCollaborator(collab, WorkspaceUserRoles.OWNER)"
>
<GeneralIcon class="text-gray-800" icon="user" />
<span>{{ $t('labels.assignAs') }}</span>
<RolesBadge :border="false" :show-icon="false" role="owner" />
</NcMenuItem>
<NcMenuItem
class="!text-red-500 !hover:bg-red-50"
@click="removeCollaborator(collab.id, currentWorkspace.id)"
>
<MaterialSymbolsDeleteOutlineRounded />
Remove user
</NcMenuItem>
@ -154,6 +218,7 @@ onMounted(async () => {
</NcDropdown>
</div>
</div>
</div>
<div v-if="sortedCollaborators.length === 1" class="pt-12 pb-4 px-2 flex flex-col items-center gap-6 text-center">
<div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }}
@ -161,8 +226,7 @@ onMounted(async () => {
<div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }}
</div>
<img src="~assets/img/placeholder/invite-team.png" class="!w-[30rem] flex-none" />
</div>
<img alt="Invite Team" class="!w-[30rem] flex-none" src="~assets/img/placeholder/invite-team.png" />
</div>
</div>
</div>
@ -170,6 +234,18 @@ onMounted(async () => {
</template>
<style scoped lang="scss">
.ant-input::placeholder {
@apply text-gray-500;
}
.ant-input:placeholder-shown {
@apply text-gray-500 !text-md;
}
.ant-input-affix-wrapper {
@apply px-4 rounded-lg py-2 w-84 border-1 focus:border-brand-500 border-gray-200 !ring-0;
}
.badge-text {
@apply text-[14px] pt-1 text-center;
}

38
packages/nc-gui/components/workspace/Settings.vue

@ -1,13 +1,17 @@
<script lang="ts" setup>
import { ref, storeToRefs, useGlobal, useI18n, useWorkspace, watch } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const { signOut } = useGlobal()
const { t } = useI18n()
const { deleteWorkspace, navigateToWorkspace, updateWorkspace } = useWorkspace()
const { workspacesList, activeWorkspaceId, activeWorkspace, workspaces } = storeToRefs(useWorkspace())
const { workspacesList, activeWorkspace, workspaces } = storeToRefs(useWorkspace())
const formValidator = ref()
@ -33,19 +37,29 @@ const formRules = {
],
}
const currentWorkspace = computed(() => {
return props.workspaceId ? workspaces.value.get(props.workspaceId) : activeWorkspace.value
})
const onDelete = async () => {
isDeleting.value = true
try {
await deleteWorkspace(activeWorkspaceId.value, { skipStateUpdate: true })
await deleteWorkspace(currentWorkspace.value.id, { skipStateUpdate: true })
isConfirmed.value = false
isDeleting.value = false
// We only remove the delete workspace from the list after the api call is successful
workspaces.value.delete(activeWorkspaceId.value)
workspaces.value.delete(currentWorkspace.value.id)
if (workspacesList.value.length > 1) {
// WorkspaceId is provided from the admin Panel. If deleted navigate to the workspace list page
if (!props.workspaceId) {
await navigateToWorkspace(workspacesList.value[0].id)
} else {
// #TODO: @DarkPhoenix2704
// Navigate BackPage
}
} else {
// As signin page will clear the workspaces, we need to check if there are more than one workspace
await signOut(false)
@ -69,7 +83,7 @@ const titleChange = async () => {
isErrored.value = false
try {
await updateWorkspace(activeWorkspaceId.value, {
await updateWorkspace(currentWorkspace.value.id, {
title: form.value.title,
})
} catch (e: any) {
@ -81,9 +95,9 @@ const titleChange = async () => {
}
watch(
() => activeWorkspace.value.title,
() => currentWorkspace.value.id,
() => {
form.value.title = activeWorkspace.value.title
form.value.title = currentWorkspace.value.title
},
{
immediate: true,
@ -94,11 +108,7 @@ watch(
() => form.value.title,
async () => {
try {
if (form.value.title !== activeWorkspace.value?.title) {
isCancelButtonVisible.value = true
} else {
isCancelButtonVisible.value = false
}
isCancelButtonVisible.value = form.value.title !== currentWorkspace.value?.title
isErrored.value = !(await formValidator.value.validate())
} catch (e: any) {
isErrored.value = true
@ -107,7 +117,7 @@ watch(
)
const onCancel = () => {
form.value.title = activeWorkspace.value?.title
form.value.title = currentWorkspace.value?.title
}
</script>
@ -140,7 +150,7 @@ const onCancel = () => {
v-e="['c:workspace:settings:rename']"
type="primary"
html-type="submit"
:disabled="isErrored || (form.title && form.title === activeWorkspace.title)"
:disabled="isErrored || (form.title && form.title === currentWorkspace.title)"
:loading="isDeleting"
data-testid="nc-workspace-settings-settings-rename-submit"
>
@ -175,7 +185,7 @@ const onCancel = () => {
</template>
<style lang="scss" scoped>
.item {
.item-card {
@apply p-6 rounded-2xl border-1 max-w-180 mt-10 min-w-100 w-full;
}
</style>

91
packages/nc-gui/components/workspace/View.vue

@ -1,5 +1,10 @@
<script lang="ts" setup>
import { useTitle } from '@vueuse/core'
import { storeToRefs } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const router = useRouter()
const route = router.currentRoute
@ -7,21 +12,38 @@ const route = router.currentRoute
const { isUIAllowed } = useRoles()
const workspaceStore = useWorkspace()
const { activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const { loadCollaborators } = workspaceStore
const { activeWorkspace: _activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const { loadCollaborators, loadWorkspace } = workspaceStore
const orgStore = useOrg()
const { orgId } = storeToRefs(orgStore)
const currentWorkspace = computedAsync(async () => {
let ws
if (props.workspaceId) {
ws = workspaces.value.get(props.workspaceId)
if (!ws) {
await loadWorkspace(props.workspaceId)
ws = workspaces.value.get(props.workspaceId)
}
} else {
ws = _activeWorkspace.value
}
return ws
})
const tab = computed({
get() {
return route.value.query?.tab ?? 'collaborators'
},
set(tab: string) {
if (tab === 'collaborators') loadCollaborators()
if (tab === 'collaborators') loadCollaborators({} as any, props.workspaceId)
router.push({ query: { ...route.value.query, tab } })
},
})
watch(
() => activeWorkspace.value?.title,
() => currentWorkspace.value?.title,
(title: string) => {
if (!title) return
@ -35,26 +57,40 @@ watch(
)
onMounted(() => {
until(() => activeWorkspace.value?.id)
until(() => currentWorkspace.value?.id)
.toMatch((v) => !!v)
.then(() => {
until(() => workspaces.value)
.toMatch((v) => v.has(activeWorkspace.value.id))
.then(() => {
loadCollaborators()
})
.then(async () => {
await loadCollaborators({} as any, currentWorkspace.value.id)
})
})
</script>
<template>
<div v-if="activeWorkspace" class="flex flex-col nc-workspace-settings">
<div class="flex gap-2 items-center min-w-0 p-6">
<GeneralWorkspaceIcon :workspace="activeWorkspace" />
<h1 class="text-3xl font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
{{ activeWorkspace?.title }}
<div v-if="currentWorkspace" class="flex w-full px-6 max-w-[97.5rem] flex-col nc-workspace-settings">
<div v-if="!props.workspaceId" class="flex gap-2 items-center min-w-0 py-6">
<GeneralWorkspaceIcon :workspace="currentWorkspace" />
<h1 class="text-3xl capitalize font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
{{ currentWorkspace?.title }}
</h1>
</div>
<div v-else>
<div class="font-bold w-full !mb-5 text-2xl" data-rec="true">
<div class="flex items-center gap-3">
<NuxtLink
:href="`/admin/${orgId}/workspaces`"
class="!hover:(text-black underline-gray-600) !text-black !underline-transparent ml-0.75 max-w-1/4"
>
{{ $t('labels.workspaces') }}
</NuxtLink>
<span class="text-2xl"> / </span>
<GeneralWorkspaceIcon :workspace="currentWorkspace" hide-label />
<span class="text-base capitalize">
{{ currentWorkspace?.title }}
</span>
</div>
</div>
</div>
<NcTabs v-model:activeKey="tab">
<template v-if="isUIAllowed('workspaceSettings')">
@ -65,7 +101,7 @@ onMounted(() => {
Members
</div>
</template>
<WorkspaceCollaboratorsList />
<WorkspaceCollaboratorsList :workspace-id="currentWorkspace.id" />
</a-tab-pane>
</template>
@ -77,7 +113,7 @@ onMounted(() => {
Settings
</div>
</template>
<WorkspaceSettings />
<WorkspaceSettings :workspace-id="currentWorkspace.id" />
</a-tab-pane>
</template>
</NcTabs>
@ -90,7 +126,24 @@ onMounted(() => {
font-size: 0.7rem;
}
.tab {
@apply flex flex-row items-center gap-x-2;
}
:deep(.ant-tabs-nav) {
@apply !pl-0;
}
:deep(.ant-tabs-nav-list) {
@apply !ml-3;
@apply !gap-5;
}
:deep(.ant-tabs-tab) {
@apply !pt-0 !pb-2.5 !ml-0;
}
.ant-tabs-content {
@apply !h-full;
}
.ant-tabs-content-top {
@apply !h-full;
}
</style>

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

@ -738,7 +738,8 @@ const [useProvideCalendarViewStore, useCalendarViewStore] = useInjectionState(
watch(activeCalendarView, async (value, oldValue) => {
if (oldValue === 'week') {
pageDate.value = selectedDate.value
selectedMonth.value = selectedDate.value ?? selectedDateRange.value.start
selectedMonth.value = selectedTime.value ?? selectedDate.value ?? selectedDateRange.value.start
selectedDate.value = selectedTime.value ?? selectedDateRange.value.start
selectedTime.value = selectedDate.value ?? selectedDateRange.value.start
} else if (oldValue === 'month') {
selectedDate.value = selectedMonth.value

66
packages/nc-gui/composables/useData.ts

@ -477,8 +477,8 @@ export function useData(args: {
try {
await $api.dbTableRow.nestedAdd(
NOCO,
base.value.title as string,
metaValue?.title as string,
base.value.id as string,
metaValue?.id as string,
encodeURIComponent(rowId),
type as RelationTypes,
column.title as string,
@ -630,23 +630,25 @@ export function useData(args: {
async function deleteSelectedRows() {
let row = formattedData.value.length
let removedRowsData: Record<string, any>[] = []
const removedRowsData: Record<string, any>[] = []
let compositePrimaryKey = ''
while (row--) {
const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any>
const { row: rowData, rowMeta } = formattedData.value[row] as Record<string, any>
if (!rowMeta.selected) {
continue
}
if (!rowMeta.new) {
const extractedPk = extractPk(meta?.value?.columns as ColumnType[])
const compositePkValue = extractPkFromRow(rowObj, meta?.value?.columns as ColumnType[])
const compositePkValue = extractPkFromRow(rowData, meta?.value?.columns as ColumnType[])
const pkData = rowPkData(rowData, meta?.value?.columns as ColumnType[])
if (extractedPk && compositePkValue) {
if (!compositePrimaryKey) compositePrimaryKey = extractedPk
removedRowsData.push({
[compositePrimaryKey]: compositePkValue as string,
pkData,
row: clone(formattedData.value[row]) as Row,
rowIndex: row as number,
})
@ -670,20 +672,7 @@ export function useData(args: {
rowObj.row = clone(fullRecord)
}
const removedRowIds: Record<string, any>[] = await bulkDeleteRows(
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
)
if (Array.isArray(removedRowIds)) {
const removedRowsDataSet = new Set(removedRowIds.map((row) => row[compositePrimaryKey]))
removedRowsData = removedRowsData.filter((row) => removedRowsDataSet.has(row[compositePrimaryKey] as string))
const rowIndexesSet = new Set(removedRowsData.map((row) => row.rowIndex))
formattedData.value = formattedData.value.filter((_, index) => rowIndexesSet.has(index))
} else {
removedRowsData = []
}
await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
} catch (e: any) {
return message.error(`${t('msg.error.deleteRowFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
@ -692,10 +681,8 @@ export function useData(args: {
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[], compositePrimaryKey: string) {
const removedRowIds = await bulkDeleteRows(
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
)
fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[]) {
const removedRowIds = await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
if (Array.isArray(removedRowIds)) {
for (const { row } of removedRowsData) {
@ -708,7 +695,7 @@ export function useData(args: {
await callbacks?.syncPagination?.()
},
args: [removedRowsData, compositePrimaryKey],
args: [removedRowsData],
},
undo: {
fn: async function undo(
@ -764,22 +751,24 @@ export function useData(args: {
// plus one because we want to include the end row
let row = start + 1
let removedRowsData: Record<string, any>[] = []
const removedRowsData: Record<string, any>[] = []
let compositePrimaryKey = ''
while (row--) {
try {
const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any>
const { row: rowData, rowMeta } = formattedData.value[row] as Record<string, any>
if (!rowMeta.new) {
const extractedPk = extractPk(meta?.value?.columns as ColumnType[])
const compositePkValue = extractPkFromRow(rowObj, meta?.value?.columns as ColumnType[])
const compositePkValue = extractPkFromRow(rowData, meta?.value?.columns as ColumnType[])
const pkData = rowPkData(rowData, meta?.value?.columns as ColumnType[])
if (extractedPk && compositePkValue) {
if (!compositePrimaryKey) compositePrimaryKey = extractedPk
removedRowsData.push({
[compositePrimaryKey]: compositePkValue as string,
pkData,
row: clone(formattedData.value[row]) as Row,
rowIndex: row as number,
})
@ -808,20 +797,7 @@ export function useData(args: {
rowObj.row = clone(fullRecord)
}
const removedRowIds: Record<string, any>[] = await bulkDeleteRows(
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
)
if (Array.isArray(removedRowIds)) {
const removedRowsDataSet = new Set(removedRowIds.map((row) => row[compositePrimaryKey]))
removedRowsData = removedRowsData.filter((row) => removedRowsDataSet.has(row[compositePrimaryKey] as string))
const rowIndexesSet = new Set(removedRowsData.map((row) => row.rowIndex))
formattedData.value = formattedData.value.filter((_, index) => rowIndexesSet.has(index))
} else {
removedRowsData = []
}
await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
} catch (e: any) {
return message.error(`${t('msg.error.deleteRowFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
@ -830,10 +806,8 @@ export function useData(args: {
addUndo({
redo: {
fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[], compositePrimaryKey: string) {
const removedRowIds = await bulkDeleteRows(
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
)
fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[]) {
const removedRowIds = await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
if (Array.isArray(removedRowIds)) {
for (const { row } of removedRowsData) {
@ -846,7 +820,7 @@ export function useData(args: {
await callbacks?.syncPagination?.()
},
args: [removedRowsData, compositePrimaryKey],
args: [removedRowsData],
},
undo: {
fn: async function undo(

2
packages/nc-gui/composables/useExpandedFormDetached/index.ts

@ -19,6 +19,8 @@ const [setup, use] = useInjectionState(() => {
return ref<UseExpandedFormDetachedProps[]>([])
})
export { setup as useExpandedFormDetachedProvider }
export function useExpandedFormDetached() {
let states = use()!

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

@ -56,6 +56,8 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
: ({ row: {}, oldRow: {}, rowMeta: {} } as Row),
)
row.value.rowMeta.fromExpandedForm = true
const rowStore = useProvideSmartsheetRowStore(row)
const activeView = inject(ActiveViewInj, ref())
@ -304,6 +306,8 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
const loadRow = async (rowId?: string, onlyVirtual = false) => {
if (row.value.rowMeta.new) return
if (isPublic.value || !meta.value?.id) return
let record = await $api.dbTableRow.read(
NOCO,

28
packages/nc-gui/composables/useLTARStore.ts

@ -12,9 +12,11 @@ import {
IsPublicInj,
Modal,
NOCO,
NcErrorType,
SharedViewPasswordInj,
computed,
extractSdkResponseErrorMsg,
extractSdkResponseErrorMsgv2,
inject,
message,
parseProp,
@ -188,15 +190,16 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
return row.value.row[displayValueProp.value]
})
const loadChildrenExcludedList = async (activeState?: any) => {
const loadChildrenExcludedList = async (activeState?: any, resetOffset: boolean = false) => {
if (activeState) newRowState.state = activeState
try {
let offset =
childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value
if (offset < 0) {
if (offset < 0 || resetOffset) {
offset = 0
childrenExcludedOffsetCount.value = 0
childrenExcludedListPagination.page = 1
}
isChildrenExcludedLoading.value = true
if (isPublic.value) {
@ -266,7 +269,11 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
// Mark out exact same objects in activeState[column.value.title] as Linked
// compare all keys and values
childrenExcludedList.value.list.forEach((row: any, index: number) => {
const found = activeState[column.value.title].find((a: any) => {
const found = (
[RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)
? [activeState[column.value.title]]
: activeState[column.value.title]
).find((a: any) => {
let isSame = true
for (const key in a) {
@ -284,27 +291,29 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
}
} catch (e: any) {
// temporary fix to handle when offset is beyond limit
if ((await extractSdkResponseErrorMsg(e)) === 'Offset is beyond the total number of records') {
const error = await extractSdkResponseErrorMsgv2(e)
if (error.error === NcErrorType.INVALID_OFFSET_VALUE) {
childrenExcludedListPagination.page = 0
return loadChildrenExcludedList(activeState)
return loadChildrenExcludedList(activeState, true)
}
message.error(`${t('msg.error.failedToLoadList')}: ${await extractSdkResponseErrorMsg(e)}`)
message.error(`${t('msg.error.failedToLoadList')}: ${error.message}`)
} finally {
isChildrenExcludedLoading.value = false
}
}
const loadChildrenList = async () => {
const loadChildrenList = async (resetOffset: boolean = false) => {
try {
isChildrenLoading.value = true
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return
if (!rowId.value || !column.value) return
let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value
if (offset < 0) {
if (offset < 0 || resetOffset) {
offset = 0
childrenListOffsetCount.value = 0
childrenListPagination.page = 1
} else if (offset >= childrenListCount.value) {
offset = 0
}
@ -347,6 +356,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenListLinked.value[index] = true
isChildrenListLoading.value[index] = false
})
if (!childrenListPagination.query) {
childrenListCount.value = childrenList.value?.pageInfo.totalRows ?? 0
}

23
packages/nc-gui/composables/useOrganization.ts

@ -0,0 +1,23 @@
export const useOrganization = () => {
const workspaces = ref([])
const members = ref([])
const bases = ref([])
const { orgId } = storeToRefs(useOrg())
const listWorkspaces = async (..._args: any) => {}
const fetchOrganizationMembers = async (..._args: any) => {}
const fetchOrganizationBases = async (..._args: any) => {}
return {
orgId,
workspaces,
listWorkspaces,
fetchOrganizationMembers,
fetchOrganizationBases,
bases,
members,
}
}

2
packages/nc-gui/composables/useSmartsheetStore.ts

@ -38,6 +38,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isMap = computed(() => view.value?.type === ViewTypes.MAP)
const isSharedForm = computed(() => isForm.value && shared)
const isDefaultView = computed(() => view.value?.is_default)
const xWhere = computed(() => {
let where
const col =
@ -100,6 +101,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
eventBus,
sqlUi,
allFilters,
isDefaultView,
}
},
'smartsheet-store',

11
packages/nc-gui/composables/useUserSorts.ts

@ -9,7 +9,7 @@ import { useGlobal } from '#imports'
* @param {string} roleType - The type of role for which user sorts are managed ('Workspace', 'Org', or 'Project').
* @returns {object} An object containing reactive values and functions related to user sorts.
*/
export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project' | 'Organization') {
const clone = rfdc()
const { user } = useGlobal()
@ -110,6 +110,8 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
userRoleOrder = Object.values(OrderedOrgRoles)
} else if (roleType === 'Project') {
userRoleOrder = Object.values(OrderedProjectRoles)
} else if (roleType === 'Organization') {
userRoleOrder = Object.values(OrderedOrgRoles)
}
data = clone(data)
@ -136,6 +138,13 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
return b[sortsConfig.field]?.localeCompare(a[sortsConfig.field])
}
}
case 'title': {
if (sortsConfig.direction === 'asc') {
return a[sortsConfig.field] - b[sortsConfig.field]
} else {
return b[sortsConfig.field] - a[sortsConfig.field]
}
}
}
return 0

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

@ -159,7 +159,12 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
$e('a:fields:show-all')
}
const saveOrUpdate = async (field: any, index: number, disableDataReload: boolean = false) => {
const saveOrUpdate = async (
field: any,
index: number,
disableDataReload: boolean = false,
updateDefaultViewColumnOrder: boolean = false,
) => {
if (isLocalMode.value && fields.value) {
fields.value[index] = field
meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => {
@ -168,6 +173,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
...column,
...field,
id: field.fk_column_id,
...(updateDefaultViewColumnOrder ? { meta: { ...parseProp(column.meta), defaultViewColOrder: field.order } } : {}),
}
}
return column

4
packages/nc-gui/composables/useViewData.ts

@ -177,7 +177,7 @@ export function useViewData(
const controller = ref()
async function loadData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) {
async function loadData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}, shouldShowLoading = true) {
if ((!base?.value?.id || !metaId.value || !viewMeta.value?.id) && !isPublic.value) return
if (controller.value) {
@ -188,7 +188,7 @@ export function useViewData(
controller.value = CancelToken.source()
isPaginationLoading.value = true
if (shouldShowLoading) isPaginationLoading.value = true
let response
try {

2
packages/nc-gui/context/index.ts

@ -57,3 +57,5 @@ export const TreeViewInj: InjectionKey<{
export const CalendarViewTypeInj: InjectionKey<Ref<'week' | 'month' | 'day' | 'year'>> = Symbol('calendar-view-type-injection')
export const JsonExpandInj: InjectionKey<Ref<boolean>> = Symbol('json-expand-injection')
export const AllFiltersInj: InjectionKey<Ref<Record<string, FilterType[]>>> = Symbol('all-filters-injection')
export const IsAdminPanelInj: InjectionKey<Ref<boolean>> = Symbol('is-admin-panel-injection')

70
packages/nc-gui/lang/ar.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Quit",
"home": "الرئيسية",
"load": "تحميل",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "مشاهد",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
},
@ -313,6 +319,10 @@
"isNotNull": "ليس فارغاً"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Docs",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "البحث عن {بحث} لم يتم العثور على نتائج",

70
packages/nc-gui/lang/bn_IN.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Quit",
"home": "বি",
"load": "ভর",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "দরশক",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
},
@ -313,6 +319,10 @@
"isNotNull": "নল নয"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Docs",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "আপনর অনসনন {search} এর জনয কনও ফলফল পওযি",

70
packages/nc-gui/lang/cs.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Quit",
"home": "Domů",
"load": "Načíst",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "Sledující",
"noaccess": "No Access",
"superAdmin": "Hlavní administrátor",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Tvůrce na úrovni organizace",
"orgLevelViewer": "Prohlížeč na úrovni organizace"
},
@ -313,6 +319,10 @@
"isNotNull": "není null"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Dokumentace",
"forum": "Fórum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Operace Vložit není v aktivní buňce podporována.",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Vaše hledání na {search} nenašlo žádné výsledky",

70
packages/nc-gui/lang/da.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Quit",
"home": "Forside",
"load": "Indlæs",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "Viewer.",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Skaberen på organisationsniveau",
"orgLevelViewer": "Visning på organisationsniveau"
},
@ -313,6 +319,10 @@
"isNotNull": "er ikke null."
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Docs",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Indsæt er ikke understøttet på den aktive celle",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Din søgning efter {Søg} viste ingen resultater",

70
packages/nc-gui/lang/de.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Beenden",
"home": "Start",
"load": "Laden",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown-Liste",
"list": "Liste",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Tag",
"week": "Woche",
"month": "Monat",
@ -247,6 +252,7 @@
"viewer": "Betrachter",
"noaccess": "Kein Zugriff",
"superAdmin": "Super-Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organisationsebenen-Ersteller",
"orgLevelViewer": "Organisationsebenen-Betrachter"
},
@ -313,6 +319,10 @@
"isNotNull": "ist nicht Null"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentifizierung (SSO)",
"docs": "Dokumentation",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Vorherige",
"nextMonth": "Folgender Monat",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Passwort eingeben",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Der Vorgang Einfügen wird auf der aktiven Zelle nicht unterstützt",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Ihre Suche nach {search} fand keine Ergebnisse",

70
packages/nc-gui/lang/en.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Quit",
"home": "Home",
"load": "Load",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "Viewer",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
},
@ -313,6 +319,10 @@
"isNotNull": "is not null"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Docs",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results"
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace":"-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "SAML",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Your search for {search} found no results",

70
packages/nc-gui/lang/es.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Salir",
"home": "Inicio",
"load": "Cargar",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Día",
"week": "Semana",
"month": "Mes",
@ -247,6 +252,7 @@
"viewer": "Visor",
"noaccess": "Sin acceso",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Creador a nivel de organización",
"orgLevelViewer": "Visor de nivel de organización"
},
@ -313,6 +319,10 @@
"isNotNull": "no es nulo"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Autenticación (SSO)",
"docs": "Documentos",
"forum": "Foro",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Seleccionar Año",
"save": "Guardar",
"cancel": "Cancelar",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "Nuevo proveedor",
"generalSettings": "Ajustes Generales",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "Ajustes SSO",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organizar por",
"previous": "Anterior",
"nextMonth": "Mes siguiente",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "La vista del calendario requiere un rango de fechas",
"goToToday": "Ir a Hoy",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Introducir la contraseña",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "Un único registro de la tabla ",
"tooltip_desc2": " puede vincularse con un único registro de la tabla "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No hay registros vinculados",
"noLinkedRecords": "No linked records",
"recordsLinked": "registros vinculados",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "No se admite la operación de pegado en la celda activa",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Tu búsqueda de {search} no encontró resultados",

70
packages/nc-gui/lang/eu.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Irten",
"home": "Hasiera",
"load": "Kargatu",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "Ikuslea",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer"
},
@ -313,6 +319,10 @@
"isNotNull": "is not null"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Dokumentuak",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Your search for {search} found no results",

70
packages/nc-gui/lang/fa.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "خروج",
"home": "خانه",
"load": "لود کردن",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "بیننده",
"noaccess": "بدون دسترسی",
"superAdmin": "مدیر ارشد",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "تولید کننده سطح سازمانی",
"orgLevelViewer": "مشاهده کننده سطح سازمانی"
},
@ -313,6 +319,10 @@
"isNotNull": "تهی نیست"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "مستندات",
"forum": "انجمن",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "عملیات جایگذاری در سلول فعال پشتیبانی نمیشود",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "جستوجوی شما برای {search} نتیجهای نداشت",

70
packages/nc-gui/lang/fi.json

@ -39,6 +39,8 @@
}
},
"general": {
"role": "Role",
"general": "General",
"quit": "Quit",
"home": "Koti",
"load": "Ladata",
@ -198,11 +200,14 @@
"logo": "Logo",
"dropdown": "Dropdown",
"list": "List",
"verify": "Verify",
"apply": "Apply",
"text": "Text",
"appearance": "Appearance"
},
"objects": {
"owner": "Owner",
"member": "Member",
"day": "Day",
"week": "Week",
"month": "Month",
@ -247,6 +252,7 @@
"viewer": "Katselija",
"noaccess": "No Access",
"superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organisaatiotason luoja",
"orgLevelViewer": "Organisaatiotason katseluohjelma"
},
@ -313,6 +319,10 @@
"isNotNull": "ei ole nolla"
},
"title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)",
"docs": "Docs",
"forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results."
},
"labels": {
"txt": "TXT Record value",
"transferOwnership": "Transfer Ownership",
"recentActivity": "Recent Activity",
"goToMembers": "Go to Members",
"addMember": "Add Member",
"numberOfMembers": "No. Members",
"numberOfBases": "No. Bases",
"numberOfRecords": "No. Records",
"workspaceName": "Workspace Name",
"workspaceWithoutOwner": "Workspace without Owners",
"inviteUsersToWorkspace": "Invite Users to Workspace",
"selectWorkspace": "-select workspaces to invite to-",
"addMembersToOrganization": "Add Members to Organization",
"memberIn": "Member in:",
"assignAs": "Assign as",
"signOutUser": "Sign out user",
"signOutUsers": "Sign out users",
"deactivateUser": "Deactivate User",
"deactivateUsers": "Deactivate Users",
"lastActive": "Last Active",
"dateAdded": "Date Added",
"uploadImage": "Upload Image",
"organizationProfile": "Organisation Profile",
"organizationImage": "Organisation Image",
"organizationName": "Organisation Name",
"activeDomains": "Active Domains",
"domains": "Domains",
"disablePublicSharing": "Disable Public Sharing",
"shareSettings": "Share Settings",
"deleteUserAndData": "Delete User and their data",
"userOptions": "User Options",
"deleteThisOrganization": "Delete this Organisation",
"dangerZone": "Dangerzone",
"selectYear": "Select Year",
"save": "Save",
"cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider",
"generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by",
"previous": "Previous",
"nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection"
},
"activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members",
"enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range",
"goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options"
},
"msg": {
"controlOrgAppearance": "Control your organisations name and appearance.",
"addCompanyDomains": "Add company domains to restrict access to unwanted users.",
"restrictUsersFromSharing": "Restrict users from being able to share bases publicly.",
"selectUsersToBeRemoved": "Select users to be removed and deleted from all organisation workspaces.",
"deleteOrganization": "Delete all users, bases and data related to this organization",
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with a single record from table "
},
"clickLinkRecordsToAddLinkFromTable": "Click 'Link Records' to begin associating data with '{tableName}'.",
"clickLinkRecordsToAddLinkFromTable": "Looks like no records have been linked yet.",
"noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records",
"recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
}
},
"info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Liitä-toimintoa ei tueta aktiivisessa solussa.",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Hakusi {haku} ei löytänyt tuloksia",

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

Loading…
Cancel
Save