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 #!/bin/bash
# set -x # 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 ********************************* # ***************** HELPER FUNCTIONS START *********************************
@ -100,9 +118,251 @@ read_number_range() {
echo "$number" 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 *********************************** # ***************** 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" 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 if ! command_exists wget; then
echo "wget is not installed. Setting up for installation..." echo "wget is not installed. Setting up for installation..."
install_package wget install_package wget
@ -135,16 +395,11 @@ for tool in docker lsof openssl; do
fi fi
done 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 # f. Port mapping check
echo " | Checking port accessibility..." echo " | Checking port accessibility..."
for port in "${REQUIRED_PORTS[@]}"; do 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 echo " | WARNING: Port $port is in use. Please make sure it is free." >&2
else else
echo " | Port $port is free." echo " | Port $port is free."
@ -165,26 +420,7 @@ if [ -z "$PUBLIC_IP" ]; then
PUBLIC_IP="localhost" PUBLIC_IP="localhost"
fi fi
# generate a folder for the docker-compose file which is not existing and do the setup within the folder message_arr+=("Setup folder: $NOCO_HOME")
# Define the folder name
FOLDER_NAME="nocodb_$(date +"%Y%m%d_%H%M%S")"
# prompt for custom folder name and if left empty skip
#echo "Enter a custom folder name or press Enter to use the default folder name ($FOLDER_NAME): "
#read CUSTOM_FOLDER_NAME
message_arr+=("Setup folder: $FOLDER_NAME")
if [ -n "$CUSTOM_FOLDER_NAME" ]; then
FOLDER_NAME="$CUSTOM_FOLDER_NAME"
fi
# Create the folder
mkdir -p "$FOLDER_NAME"
# Navigate into the folder
cd "$FOLDER_NAME" || exit
# ******************** SYSTEM REQUIREMENTS CHECK END ************************** # ******************** SYSTEM REQUIREMENTS CHECK END **************************
# ****************************************************************************** # ******************************************************************************
@ -223,7 +459,7 @@ fi
if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; then if [ -n "$EDITION" ] && { [ "$EDITION" = "EE" ] || [ "$EDITION" = "ee" ]; }; then
echo "Enter the NocoDB license key: " echo "Enter the NocoDB license key: "
read LICENSE_KEY read -r LICENSE_KEY
if [ -z "$LICENSE_KEY" ]; then if [ -z "$LICENSE_KEY" ]; then
echo "License key is required for Enterprise Edition installation" echo "License key is required for Enterprise Edition installation"
exit 1 exit 1
@ -535,211 +771,6 @@ server {
EOF EOF
fi 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 cat > ./update.sh <<EOF
$DOCKER_COMMAND compose pull $DOCKER_COMMAND compose pull
$DOCKER_COMMAND compose up -d --force-recreate $DOCKER_COMMAND compose up -d --force-recreate
@ -781,4 +812,11 @@ fi
print_box_message "${message_arr[@]}" print_box_message "${message_arr[@]}"
# *************************** SETUP END ************************************* # *************************** 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 <nuxt-link
v-else-if="validEmail" v-else-if="validEmail"
no-ref 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}`" :href="`mailto:${vModel}`"
target="_blank" target="_blank"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"

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

@ -121,7 +121,7 @@ watch(
v-else-if="isValid && !cellUrlOptions?.overlay" v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel 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" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"
@ -133,7 +133,7 @@ watch(
v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay" v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel 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" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :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 () => { const logout = async () => {
isLoggingOut.value = true isLoggingOut.value = true
try { try {
const isSsoUser = !!(user?.value as any)?.sso_client_id
await signOut(false) await signOut(false)
// No need as all stores are cleared on signout // No need as all stores are cleared on signout
// await clearWorkspaces() // await clearWorkspaces()
await navigateTo('/signin') await navigateTo(isSsoUser ? '/sso' : '/signin')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
@ -167,6 +169,8 @@ onMounted(() => {
<NcDivider /> <NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile"> <nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem> <NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link> </nuxt-link>

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

@ -290,11 +290,11 @@ const isEditBaseModalOpen = computed({
<template> <template>
<div class="flex flex-row w-full h-full nc-data-sources-view"> <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-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 <NcButton
v-if="dataSourcesAwakened" v-if="dataSourcesAwakened"
size="large" size="large"
class="z-10 !rounded-lg !px-2 mr-2.5" class="z-10 !px-2"
type="primary" type="primary"
@click="vState = DataSourcesSubTab.New" @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"> <script lang="ts" setup>
import type { RoleLabels } from 'nocodb-sdk' import { ProjectRoles, type RoleLabels, WorkspaceUserRoles } from 'nocodb-sdk'
import { OrderedProjectRoles, ProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports' import type { User } from '#imports'
import { extractEmail } from '~/helpers/parsers/parserHelpers' import { extractEmail } from '~/helpers/parsers/parserHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
type?: 'base' | 'workspace' | 'organization'
baseId?: string baseId?: string
emails?: string[]
workspaceId?: string
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const dialogShow = useVModel(props, 'modelValue', emit) const { baseRoles, workspaceRoles } = useRoles()
const inviteData = reactive({
email: '',
roles: ProjectRoles.NO_ACCESS,
})
const { baseRoles } = useRoles()
const basesStore = useBases() const basesStore = useBases()
const { activeProjectId } = storeToRefs(basesStore) const workspaceStore = useWorkspace()
const { createProjectUser } = basesStore 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 divRef = ref<HTMLDivElement>()
const focusRef = ref<HTMLInputElement>() const focusRef = ref<HTMLInputElement>()
@ -35,23 +48,44 @@ const emailValidation = reactive({
message: '', message: '',
}) })
const allowedRoles = ref<ProjectRoles[]>([]) const singleEmailValue = ref('')
onMounted(async () => { const emailBadges = ref<Array<string>>([])
try {
const currentRoleIndex = OrderedProjectRoles.findIndex( const allowedRoles = ref<[]>([])
(role) => baseRoles.value && Object.keys(baseRoles.value).includes(role),
) const focusOnDiv = () => {
if (currentRoleIndex !== -1) { focusRef.value?.focus()
allowedRoles.value = OrderedProjectRoles.slice(currentRoleIndex + 1).filter((r) => r) 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) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) 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) => { const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array // Check if the string already exists in the array
@ -84,7 +118,7 @@ const emailInputValidation = (input: string, isBulkEmailCopyPaste: boolean = fal
return true return true
} }
const isInvitButtonDiabled = computed(() => { const isInviteButtonDisabled = computed(() => {
if (!emailBadges.value.length && !singleEmailValue.value.length) { if (!emailBadges.value.length && !singleEmailValue.value.length) {
return true return true
} }
@ -95,7 +129,7 @@ const isInvitButtonDiabled = computed(() => {
watch(inviteData, (newVal) => { watch(inviteData, (newVal) => {
// when user only want to enter a single email // 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) const isSingleEmailValid = validateEmail(newVal.email)
if (isSingleEmailValid && !emailBadges.value.length) { if (isSingleEmailValid && !emailBadges.value.length) {
@ -105,7 +139,7 @@ watch(inviteData, (newVal) => {
} }
singleEmailValue.value = '' 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) === ' ' const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length) { if (isNewEmail && newVal.email.trim().length) {
const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim() const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim()
@ -140,12 +174,6 @@ const handleEnter = () => {
emailValidation.isError = false emailValidation.isError = false
emailValidation.message = '' emailValidation.message = ''
} }
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace // remove one email per backspace
onKeyStroke('Backspace', () => { onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) { if (isDivFocused.value && inviteData.email.length < 1) {
@ -197,7 +225,9 @@ const onPaste = (e: ClipboardEvent) => {
inviteData.email = '' inviteData.email = ''
} }
const inviteProjectCollaborator = async () => { const workSpaces = ref<NcWorkspace[]>([])
const inviteCollaborator = async () => {
try { try {
const payloadData = singleEmailValue.value || emailBadges.value.join(',') const payloadData = singleEmailValue.value || emailBadges.value.join(',')
if (!payloadData.includes(',')) { if (!payloadData.includes(',')) {
@ -207,10 +237,19 @@ const inviteProjectCollaborator = async () => {
emailValidation.message = 'invalid email' emailValidation.message = 'invalid email'
} }
} }
await createProjectUser(activeProjectId.value!, { if (props.type === 'base' && props.baseId) {
await createProjectUser(props.baseId!, {
email: payloadData, email: payloadData,
roles: inviteData.roles, roles: inviteData.roles,
} as unknown as User) } 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') message.success('Invitation sent successfully')
inviteData.email = '' 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> </script>
<template> <template>
<NcModal <NcModal
v-model:visible="dialogShow" v-model:visible="dialogShow"
:show-separator="false"
:header="$t('activity.createTable')" :header="$t('activity.createTable')"
:show-separator="false"
size="medium" size="medium"
class="nc-invite-dlg"
@keydown.esc="dialogShow = false" @keydown.esc="dialogShow = false"
> >
<template #header> <template #header>
<div class="flex flex-row items-center gap-x-2"> <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> </div>
</template> </template>
<div class="flex items-center justify-between gap-3 mt-2"> <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 class="flex justify-between gap-3 w-full">
<div <div
ref="divRef" 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="{ :class="{
'border-primary/100': isDivFocused, 'border-primary/100': isDivFocused,
'p-1': emailBadges?.length > 1, '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" @blur="isDivFocused = false"
@click="focusOnDiv"
> >
<span <span
v-for="(email, index) in emailBadges" v-for="(email, index) in emailBadges"
:key="email" :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 }} {{ email }}
<component <component
@ -272,38 +341,65 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
:placeholder="$t('activity.enterEmail')" :placeholder="$t('activity.enterEmail')"
class="w-full min-w-36 outline-none px-2" class="w-full min-w-36 outline-none px-2"
data-testid="email-input" data-testid="email-input"
@keyup.enter="handleEnter"
@blur="isDivFocused = false" @blur="isDivFocused = false"
@keyup.enter="handleEnter"
@paste.prevent="onPaste" @paste.prevent="onPaste"
/> />
</div> </div>
<RolesSelector <RolesSelector
size="lg" :description="false"
class="nc-invite-role-selector" :on-role-change="onRoleChange"
:role="inviteData.roles" :role="inviteData.roles"
:roles="allowedRoles" :roles="allowedRoles"
:on-role-change="onRoleChange" class="!min-w-[152px] nc-invite-role-selector"
:description="false" size="lg"
/> />
</div> </div>
<span v-if="emailValidation.isError && emailValidation.message" class="ml-2 text-red-500 text-[10px] mt-1.5">{{ <span v-if="emailValidation.isError && emailValidation.message" class="ml-2 text-red-500 text-[10px] mt-1.5">{{
emailValidation.message emailValidation.message
}}</span> }}</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> </div>
<div class="flex mt-8 justify-end"> <div class="flex mt-8 justify-end">
<div class="flex gap-2"> <div class="flex gap-2">
<NcButton type="secondary" @click="dialogShow = false"> {{ $t('labels.cancel') }} </NcButton> <NcButton type="secondary" @click="dialogShow = false"> {{ $t('labels.cancel') }} </NcButton>
<NcButton <NcButton
type="primary" :disabled="isInviteButtonDisabled || emailValidation.isError"
size="medium" size="medium"
:disabled="isInvitButtonDiabled || emailValidation.isError" type="primary"
@click="inviteProjectCollaborator" class="nc-invite-btn"
@click="inviteCollaborator"
> >
{{ $t('activity.inviteToBase') }} {{ type === 'base' ? $t('activity.inviteToBase') : $t('activity.inviteToWorkspace') }}
</NcButton> </NcButton>
</div> </div>
</div> </div>
</NcModal> </NcModal>
</template> </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<{ defineProps<{
type?: NcProjectType | string type?: NcProjectType | string
modelValue?: string modelValue?: string
size?: 'small' | 'medium' | 'large' | 'xlarge' size?: 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge'
readonly?: boolean readonly?: boolean
iconClass?: string iconClass?: string
}>(), }>(),
@ -62,6 +62,7 @@ watch(
:class="{ :class="{
'hover:bg-gray-500 hover:bg-opacity-15 cursor-pointer': !readonly, 'hover:bg-gray-500 hover:bg-opacity-15 cursor-pointer': !readonly,
'bg-gray-500 bg-opacity-15': isOpen, 'bg-gray-500 bg-opacity-15': isOpen,
'h-5 w-5 text-base': size === 'xsmall',
'h-6 w-6 text-lg': size === 'small', 'h-6 w-6 text-lg': size === 'small',
'h-8 w-8 text-xl': size === 'medium', 'h-8 w-8 text-xl': size === 'medium',
'h-10 w-10 text-2xl': size === 'large', '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 workspace: WorkspaceType | undefined
hideLabel?: boolean hideLabel?: boolean
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
isRounded?: boolean
}>() }>()
const workspaceColor = computed(() => { 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-4 w-4 h-4 rounded': size === 'small',
'min-w-6 w-6 h-6 rounded-md': size === 'medium', 'min-w-6 w-6 h-6 rounded-md': size === 'medium',
'min-w-10 w-10 h-10 rounded-lg !text-base': size === 'large', 'min-w-10 w-10 h-10 rounded-lg !text-base': size === 'large',
'!rounded-[50%]': props.isRounded,
}" }"
:style="{ backgroundColor: workspaceColor }" :style="{ backgroundColor: workspaceColor }"
> >

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

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

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

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

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

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

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

@ -1,27 +1,44 @@
<script lang="ts" setup> <script lang="ts" setup>
import {
OrderedProjectRoles,
OrgUserRoles,
ProjectRoles,
WorkspaceRolesToProjectRoles,
extractRolesObj,
parseStringDateTime,
timeAgo,
} from 'nocodb-sdk'
import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk' import type { Roles, WorkspaceUserRoles } from 'nocodb-sdk'
import { OrderedProjectRoles, OrgUserRoles, ProjectRoles, WorkspaceRolesToProjectRoles } from 'nocodb-sdk'
import type { User } from '#imports' import type { User } from '#imports'
import { isEeUI, storeToRefs, useUserSorts } from '#imports' import { isEeUI, storeToRefs, useUserSorts } from '#imports'
const props = defineProps<{
baseId?: string
}>()
const basesStore = useBases() const basesStore = useBases()
const { getBaseUsers, createProjectUser, updateProjectUser, removeProjectUser } = basesStore 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 { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Project')
const isSuper = computed(() => orgRoles.value?.[OrgUserRoles.SUPER_ADMIN]) 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) const isInviteModalVisible = ref(false)
interface Collaborators { interface Collaborators {
@ -56,8 +73,9 @@ const sortedCollaborators = computed(() => {
const loadCollaborators = async () => { const loadCollaborators = async () => {
try { try {
if (!currentBase.value) return
const { users, totalRows } = await getBaseUsers({ const { users, totalRows } = await getBaseUsers({
baseId: activeProjectId.value!, baseId: currentBase.value.id!,
...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)), ...(!userSearchText.value ? {} : ({ searchText: userSearchText.value } as any)),
force: true, force: true,
}) })
@ -69,9 +87,8 @@ const loadCollaborators = async () => {
.map((user: any) => ({ .map((user: any) => ({
...user, ...user,
base_roles: user.roles, base_roles: user.roles,
roles: extractRolesObj(user.main_roles)?.[OrgUserRoles.SUPER_ADMIN] roles:
? OrgUserRoles.SUPER_ADMIN user.roles ??
: user.roles ??
(user.workspace_roles (user.workspace_roles
? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS ? WorkspaceRolesToProjectRoles[user.workspace_roles as WorkspaceUserRoles] ?? ProjectRoles.NO_ACCESS
: ProjectRoles.NO_ACCESS), : ProjectRoles.NO_ACCESS),
@ -93,7 +110,7 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles && WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
isEeUI) isEeUI)
) { ) {
await removeProjectUser(activeProjectId.value!, currentCollaborator as unknown as User) await removeProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
if ( if (
currentCollaborator.workspace_roles && currentCollaborator.workspace_roles &&
WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles && WorkspaceRolesToProjectRoles[currentCollaborator.workspace_roles as WorkspaceUserRoles] === roles &&
@ -105,11 +122,11 @@ const updateCollaborator = async (collab: any, roles: ProjectRoles) => {
} }
} else if (currentCollaborator.base_roles) { } else if (currentCollaborator.base_roles) {
currentCollaborator.roles = roles currentCollaborator.roles = roles
await updateProjectUser(activeProjectId.value!, currentCollaborator as unknown as User) await updateProjectUser(currentBase.value.id!, currentCollaborator as unknown as User)
} else { } else {
currentCollaborator.roles = roles currentCollaborator.roles = roles
currentCollaborator.base_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) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -142,24 +159,50 @@ watch(isInviteModalVisible, () => {
loadCollaborators() loadCollaborators()
} }
}) })
watch(currentBase, () => {
loadCollaborators()
})
</script> </script>
<template> <template>
<div class="nc-collaborator-table-container mt-4 nc-access-settings-view h-[calc(100vh-8rem)]"> <div
<LazyProjectShareBaseDlg v-model:model-value="isInviteModalVisible" /> :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"> <div v-if="isLoading" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" /> <GeneralLoader size="xlarge" />
</div> </div>
<template v-else> <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"> <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" class="!max-w-90 !rounded-md" :placeholder="$t('title.searchMembers')"> <a-input v-model:value="userSearchText" :placeholder="$t('title.searchMembers')" class="!max-w-90 !rounded-md mr-4">
<template #prefix> <template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" /> <PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template> </template>
</a-input> </a-input>
<NcButton size="small" @click="isInviteModalVisible = true"> <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" /> <component :is="iconMap.plus" class="w-4 h-4" />
{{ $t('activity.addMembers') }} {{ $t('activity.addMembers') }}
</div> </div>
@ -188,7 +231,7 @@ watch(isInviteModalVisible, () => {
<div class="text-gray-700 user-access-grid flex items-center space-x-2"> <div class="text-gray-700 user-access-grid flex items-center space-x-2">
<span> <span>
{{ $t('general.access') }} {{ $t('general.role') }}
</span> </span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" /> <LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div> </div>
@ -203,18 +246,17 @@ watch(isInviteModalVisible, () => {
> >
<div class="flex gap-3 items-center users-email-grid"> <div class="flex gap-3 items-center users-email-grid">
<GeneralUserIcon size="base" :email="collab.email" /> <GeneralUserIcon size="base" :email="collab.email" />
<NcTooltip v-if="collab.display_name"> <div class="flex flex-col">
<template #title> <div class="flex gap-3">
{{ collab.email }} <span class="text-gray-800 capitalize font-semibold">
</template> {{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }}
<span class="truncate">
{{ collab.display_name }}
</span> </span>
</NcTooltip> </div>
<span v-else class="truncate"> <span class="text-xs text-gray-600">
{{ collab.email }} {{ collab.email }}
</span> </span>
</div> </div>
</div>
<div class="user-access-grid"> <div class="user-access-grid">
<template v-if="accessibleRoles.includes(collab.roles)"> <template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector <RolesSelector
@ -230,7 +272,7 @@ watch(isInviteModalVisible, () => {
/> />
</template> </template>
<template v-else> <template v-else>
<RolesBadge :role="collab.roles" /> <RolesBadge :border="false" :role="collab.roles" />
</template> </template>
</div> </div>
<div class="date-joined-grid"> <div class="date-joined-grid">
@ -252,6 +294,18 @@ watch(isInviteModalVisible, () => {
</template> </template>
<style scoped lang="scss"> <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 { .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; @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 NcLayout from '~icons/nc-icons/layout'
import { isEeUI } from '#imports' import { isEeUI } from '#imports'
const props = defineProps<{
baseId: string
}>()
const basesStore = useBases() const basesStore = useBases()
const { openedProject, activeProjectId, basesUser } = storeToRefs(basesStore) const { openedProject, activeProjectId, basesUser, bases } = storeToRefs(basesStore)
const { activeTables, activeTable } = storeToRefs(useTablesStore()) const { activeTables, activeTable } = storeToRefs(useTablesStore())
const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace()) const { activeWorkspace, workspaceUserCount } = storeToRefs(useWorkspace())
const { navigateToProjectPage } = useBase() const { navigateToProjectPage } = useBase()
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const router = useRouter() const router = useRouter()
const route = router.currentRoute 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 base
return openedProject.value?.sources?.[0] })
}) */
const { isUIAllowed, baseRoles } = useRoles() const { isUIAllowed, baseRoles } = useRoles()
@ -37,7 +51,7 @@ const userCount = computed(() =>
watch( watch(
() => route.value.query?.page, () => route.value.query?.page,
(newVal, oldVal) => { (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 && newVal !== oldVal) {
if (newVal === 'collaborator') { if (newVal === 'collaborator') {
projectPageTab.value = 'collaborator' projectPageTab.value = 'collaborator'
@ -46,11 +60,14 @@ watch(
} else { } else {
projectPageTab.value = 'allTable' projectPageTab.value = 'allTable'
} }
return return
} }
if (isAdminPanel.value) {
projectPageTab.value = 'collaborator'
} else {
projectPageTab.value = 'allTable' projectPageTab.value = 'allTable'
}
}, },
{ immediate: true }, { immediate: true },
) )
@ -66,11 +83,11 @@ watch(projectPageTab, () => {
}) })
watch( watch(
() => [openedProject.value?.id, openedProject.value?.title], () => [currentBase.value?.id, currentBase.value?.title],
() => { () => {
if (activeTable.value?.title) return if (activeTable.value?.title) return
useTitle(`${openedProject.value?.title ?? activeWorkspace.value?.title ?? 'NocoDB'}`) useTitle(`${currentBase.value?.title ?? activeWorkspace.value?.title ?? 'NocoDB'}`)
}, },
{ {
immediate: true, immediate: true,
@ -81,17 +98,18 @@ watch(
<template> <template>
<div class="h-full nc-base-view"> <div class="h-full nc-base-view">
<div <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="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 }" :class="{ 'nc-table-toolbar-mobile': isMobileMode, 'h-[var(--topbar-height)]': !isMobileMode }"
> >
<div class="flex flex-row items-center gap-x-3"> <div class="flex flex-row items-center gap-x-3">
<GeneralOpenLeftSidebarBtn /> <GeneralOpenLeftSidebarBtn />
<div class="flex flex-row items-center h-full gap-x-2.5"> <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> <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"> <span class="truncate">
{{ openedProject?.title }} {{ currentBase?.title }}
</span> </span>
</NcTooltip> </NcTooltip>
</div> </div>
@ -105,7 +123,7 @@ watch(
}" }"
> >
<a-tabs v-model:activeKey="projectPageTab" class="w-full"> <a-tabs v-model:activeKey="projectPageTab" class="w-full">
<a-tab-pane key="allTable"> <a-tab-pane v-if="!isAdminPanel" key="allTable">
<template #tab> <template #tab>
<div class="tab-title" data-testid="proj-view-tab__all-tables"> <div class="tab-title" data-testid="proj-view-tab__all-tables">
<NcLayout /> <NcLayout />
@ -143,7 +161,7 @@ watch(
</div> </div>
</div> </div>
</template> </template>
<ProjectAccessSettings /> <ProjectAccessSettings :base-id="currentBase.id" />
</a-tab-pane> </a-tab-pane>
<a-tab-pane v-if="isUIAllowed('sourceCreate')" key="data-source"> <a-tab-pane v-if="isUIAllowed('sourceCreate')" key="data-source">
<template #tab> <template #tab>

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

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

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

@ -1,11 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { RoleDescriptions } from 'nocodb-sdk'
import type { RoleLabels } from 'nocodb-sdk' import type { RoleLabels } from 'nocodb-sdk'
import { RoleDescriptions } from 'nocodb-sdk'
import type { SelectValue } from 'ant-design-vue/es/select' import type { SelectValue } from 'ant-design-vue/es/select'
import { toRef } from '#imports' import { toRef } from '#imports'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
border?: boolean
role: keyof typeof RoleLabels role: keyof typeof RoleLabels
roles: (keyof typeof RoleLabels)[] roles: (keyof typeof RoleLabels)[]
description?: boolean description?: boolean
@ -14,6 +15,7 @@ const props = withDefaults(
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
}>(), }>(),
{ {
border: true,
description: true, description: true,
size: 'sm', size: 'sm',
}, },
@ -36,7 +38,7 @@ function onChangeRole(val: SelectValue) {
<template> <template>
<div ref="dropdownRef" size="lg" class="nc-roles-selector relative" @click="isDropdownOpen = !isDropdownOpen"> <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 <a-select
:value="roleRef" :value="roleRef"
:open="isDropdownOpen" :open="isDropdownOpen"
@ -54,7 +56,7 @@ function onChangeRole(val: SelectValue) {
class="flex flex-col nc-role-select-dropdown gap-1" class="flex flex-col nc-role-select-dropdown gap-1"
> >
<div class="flex items-center justify-between"> <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" /> <GeneralIcon v-if="rl === roleRef" icon="check" class="text-primary" />
</div> </div>
<div v-if="descriptionRef" class="text-gray-500 text-xs">{{ RoleDescriptions[rl] }}</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 }, { 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({ const vModel = computed({
get: () => { get: () => {
return props.modelValue return props.modelValue
@ -122,7 +133,9 @@ const vModel = computed({
} else if (val !== props.modelValue) { } else if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true currentRow.value.rowMeta.changed = true
emit('update:modelValue', val) emit('update:modelValue', val)
if (isAutoSaved(column.value)) { if (column.value.pk) {
updateWhenEditCompleted()
} else if (isAutoSaved(column.value)) {
syncValue() syncValue()
} else if (!isManualSaved(column.value)) { } else if (!isManualSaved(column.value)) {
emit('save') 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 { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType) => { const fieldStyles = computed(() => {
const fi = _fields.value?.find((f) => f.title === field.title) 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 { const getFieldStyle = (field: ColumnType) => {
underline: fi?.underline, return fieldStyles.value.get(field.id)
bold: fi?.bold,
italic: fi?.italic,
}
} }
// We loop through all the records and calculate the position of each record based on the range // 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 // 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[]>(() => { 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 { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType) => { const fieldStyles = computed(() => {
if (!_fields.value) return { underline: false, bold: false, italic: false } if (!_fields.value) return new Map()
const fi = _fields.value.find((f) => f.title === field.title) return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return { const getFieldStyle = (field: ColumnType) => {
underline: fi?.underline, return fieldStyles.value.get(field.id)
bold: fi?.bold,
italic: fi?.italic,
}
} }
const hours = computed(() => { const hours = computed(() => {
@ -49,7 +56,8 @@ const hours = computed(() => {
return hours return hours
}) })
const calculateNewDates = ({ const calculateNewDates = useMemoize(
({
endDate, endDate,
startDate, startDate,
scheduleStart, scheduleStart,
@ -78,7 +86,8 @@ const calculateNewDates = ({
} }
return { endDate, startDate } return { endDate, startDate }
} },
)
const getGridTime = (date: dayjs.Dayjs, round = false) => { const getGridTime = (date: dayjs.Dayjs, round = false) => {
const gridCalc = date.hour() * 60 + date.minute() const gridCalc = date.hour() * 60 + date.minute()
@ -133,35 +142,14 @@ const hasSlotForRecord = (
} }
const getMaxOverlaps = ({ const getMaxOverlaps = ({
row, row,
gridTimeMap,
columnArray, columnArray,
graph,
}: { }: {
row: Row row: Row
gridTimeMap: Map<
number,
{
count: number
id: string[]
}
>
columnArray: Array<Array<Row>> columnArray: Array<Array<Row>>
graph: Map<string, Set<string>>
}) => { }) => {
const visited: Set<string> = new Set() 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 => { const dfs = (id: string): number => {
visited.add(id) visited.add(id)
@ -169,6 +157,7 @@ const getMaxOverlaps = ({
const neighbors = graph.get(id) const neighbors = graph.get(id)
if (neighbors) { if (neighbors) {
for (const neighbor of neighbors) { for (const neighbor of neighbors) {
if (maxOverlaps >= columnArray.length) return maxOverlaps
if (!visited.has(neighbor)) { if (!visited.has(neighbor)) {
maxOverlaps = Math.min(Math.max(maxOverlaps, dfs(neighbor) + 1), columnArray.length) maxOverlaps = Math.min(Math.max(maxOverlaps, dfs(neighbor) + 1), columnArray.length)
} }
@ -187,32 +176,19 @@ const getMaxOverlaps = ({
const recordsAcrossAllRange = computed<{ const recordsAcrossAllRange = computed<{
record: Row[] record: Row[]
count: { gridTimeMap: Map<
[key: string]: { number,
{
count: number
id: string[] id: string[]
overflow: boolean
overflowCount: number
}
} }
>
}>(() => { }>(() => {
if (!calendarRange.value || !formattedData.value) return { record: [], count: {} } if (!calendarRange.value || !formattedData.value) return { record: [], count: {} }
const scheduleStart = dayjs(selectedDate.value).startOf('day') const scheduleStart = dayjs(selectedDate.value).startOf('day')
const scheduleEnd = dayjs(selectedDate.value).endOf('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 perRecordHeight = 52
const columnArray: Array<Array<Row>> = [[]] const columnArray: Array<Array<Row>> = [[]]
@ -400,11 +376,28 @@ const recordsAcrossAllRange = computed<{
record.rowMeta.overLapIteration = parseInt(columnIndex) + 1 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) { for (const record of recordsByRange) {
const numberOfOverlaps = getMaxOverlaps({ const numberOfOverlaps = getMaxOverlaps({
row: record, row: record,
gridTimeMap,
columnArray, columnArray,
graph,
}) })
record.rowMeta.numberOfOverlaps = numberOfOverlaps record.rowMeta.numberOfOverlaps = numberOfOverlaps
@ -418,24 +411,6 @@ const recordsAcrossAllRange = computed<{
if (record.rowMeta.overLapIteration! - 1 > 7) { if (record.rowMeta.overLapIteration! - 1 > 7) {
display = 'none' 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 { } else {
left = width * (record.rowMeta.overLapIteration! - 1) left = width * (record.rowMeta.overLapIteration! - 1)
} }
@ -453,7 +428,7 @@ const recordsAcrossAllRange = computed<{
} }
return { return {
count: overlaps, gridTimeMap,
record: recordsByRange, record: recordsByRange,
} }
}) })
@ -477,7 +452,7 @@ const useDebouncedRowUpdate = useDebounceFn((row: Row, updateProperty: string[],
}, 500) }, 500)
// When the user is dragging a record, we calculate the new start and end date based on the mouse position // 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: [] } if (!container.value || !dragRecord.value) return { newRow: null, updateProperty: [] }
const { top } = container.value.getBoundingClientRect() const { top } = container.value.getBoundingClientRect()
@ -505,7 +480,7 @@ const calculateNewRow = (event: MouseEvent) => {
...dragRecord.value, ...dragRecord.value,
row: { row: {
...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() 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!) 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) { if (!newRow) {
return { newRow: null, updateProperty: [] } return { newRow: null, updateProperty: [] }
} }
@ -552,6 +532,11 @@ const calculateNewRow = (event: MouseEvent) => {
const pk = extractPkFromRow(r.row, meta.value!.columns!) const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk !== newPk return pk !== newPk
}) })
dragRecord.value = {
...dragRecord.value,
row: newRow.row,
}
} }
return { newRow, updateProperty } return { newRow, updateProperty }
} }
@ -668,7 +653,7 @@ const stopDrag = (event: MouseEvent) => {
clearTimeout(dragTimeout.value!) clearTimeout(dragTimeout.value!)
if (!isUIAllowed('dataEdit') || !isDragging.value || !container.value || !dragRecord.value) return 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 if (!newRow && !updateProperty) return
const allRecords = document.querySelectorAll('.draggable-record') const allRecords = document.querySelectorAll('.draggable-record')
@ -823,32 +808,18 @@ const dropEvent = (event: DragEvent) => {
} }
const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => { const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => {
let startOfHour = hour.startOf('hour') if (!recordsAcrossAllRange.value || !recordsAcrossAllRange.value.gridTimeMap) return { isOverflow: false, overflowCount: 0 }
const endOfHour = hour.endOf('hour') const { gridTimeMap } = recordsAcrossAllRange.value
const startMinute = hour.hour() * 60 + hour.minute()
const ids: Array<string> = [] const endMinute = hour.hour() * 60 + hour.minute() + 59
let isOverflow = false
let overflowCount = 0 let overflowCount = 0
while (startOfHour.isBefore(endOfHour, 'minute')) { for (let minute = startMinute; minute <= endMinute; minute++) {
const hourKey = startOfHour.hour() * 60 + startOfHour.minute() const recordCount = gridTimeMap.get(minute)?.count ?? 0
if (recordsAcrossAllRange.value?.count?.[hourKey]?.overflow) { overflowCount = Math.max(overflowCount, recordCount)
isOverflow = true
recordsAcrossAllRange.value?.count?.[hourKey]?.id.forEach((id) => {
if (!ids.includes(id)) {
ids.push(id)
overflowCount += 1
}
})
} }
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) => { 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 { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => { const fieldStyles = computed(() => {
if (!field) return { underline: false, bold: false, italic: false } if (!_fields.value) return new Map()
const fi = _fields.value?.find((f) => f.title === field.title) return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return { const getFieldStyle = (field: ColumnType) => {
underline: fi?.underline, return fieldStyles.value.get(field.id)
bold: fi?.bold,
italic: fi?.italic,
}
} }
const dates = computed(() => { 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 { top, height, width, left } = calendarGridContainer.value.getBoundingClientRect()
const percentY = (event.clientY - top - window.scrollY) / height const percentY = (event.clientY - top - window.scrollY) / height
@ -364,7 +371,7 @@ const calculateNewRow = (event: MouseEvent, updateSideBar?: boolean) => {
...dragRecord.value, ...dragRecord.value,
row: { row: {
...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() 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!) 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: [] } if (!newRow) return { newRow: null, updateProperty: [] }
const newPk = extractPkFromRow(newRow.row, meta.value!.columns!) const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -515,7 +527,7 @@ const stopDrag = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
dragElement.value!.style.boxShadow = 'none' dragElement.value!.style.boxShadow = 'none'
const { newRow, updateProperty } = calculateNewRow(event, false) const { newRow, updateProperty } = calculateNewRow(event, false, true)
const allRecords = document.querySelectorAll('.draggable-record') const allRecords = document.querySelectorAll('.draggable-record')
allRecords.forEach((el) => { allRecords.forEach((el) => {

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

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { type ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib' import type { Row } from '~/lib'
import { computed, ref, useViewColumnsOrThrow } from '#imports' import { computed, ref, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils' import { generateRandomNumber, isRowEmpty } from '~/utils'
@ -22,14 +22,22 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow() const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => { const fieldStyles = computed(() => {
const fi = _fields.value?.find((f) => f.title === field?.title) 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 { const getFieldStyle = (field: ColumnType) => {
underline: fi?.underline, return fieldStyles.value.get(field.id)
bold: fi?.bold,
italic: fi?.italic,
}
} }
// Calculate the dates of the week // 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(() => { const calendarData = computed(() => {
if (!formattedData.value || !calendarRange.value) return [] if (!formattedData.value || !calendarRange.value) return []
@ -156,9 +176,8 @@ const calendarData = computed(() => {
let position = 'none' let position = 'none'
const isStartInRange = const isStartInRange = isInRange(ogStartDate)
ogStartDate && ogStartDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]') const isEndInRange = isInRange(endDate)
const isEndInRange = endDate && endDate.isBetween(selectedDateRange.value.start, selectedDateRange.value.end, 'day', '[]')
// Calculate the position of the record in the calendar based on the start and end date // Calculate the position of the record in the calendar based on the start and end date
// The position can be 'none', 'leftRounded', 'rightRounded', 'rounded' // 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> <script lang="ts" setup>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { type ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import type { Row } from '~/lib' import type { Row } from '~/lib'
import { computed, ref, useViewColumnsOrThrow } from '#imports' import { computed, ref, useMemoize, useViewColumnsOrThrow } from '#imports'
import { generateRandomNumber, isRowEmpty } from '~/utils' import { generateRandomNumber, isRowEmpty } from '~/utils'
const emits = defineEmits(['expandRecord', 'newRecord']) const emits = defineEmits(['expandRecord', 'newRecord'])
@ -14,7 +14,6 @@ const {
calendarRange, calendarRange,
displayField, displayField,
selectedTime, selectedTime,
selectedDate,
updateRowProperty, updateRowProperty,
sideBarFilterOption, sideBarFilterOption,
showSideMenu, showSideMenu,
@ -34,17 +33,54 @@ const fields = inject(FieldsInj, ref())
const { fields: _fields } = useViewColumnsOrThrow() const { fields: _fields } = useViewColumnsOrThrow()
const getFieldStyle = (field: ColumnType | undefined) => { const fieldStyles = computed(() => {
if (!field) return { underline: false, bold: false, italic: false } if (!_fields.value) return new Map()
const fi = _fields.value?.find((f) => f.title === field.title) return new Map(
_fields.value.map((field) => [
field.fk_column_id,
{
underline: field.underline,
bold: field.bold,
italic: field.italic,
},
]),
)
})
return { const getFieldStyle = (field: ColumnType) => {
underline: fi?.underline, return fieldStyles.value.get(field.id)
bold: fi?.bold, }
italic: fi?.italic, 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 // 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 = computed(() => {
const datesHours: Array<Array<dayjs.Dayjs>> = [] const datesHours: Array<Array<dayjs.Dayjs>> = []
@ -71,167 +107,178 @@ const datesHours = computed(() => {
return datesHours return datesHours
}) })
const recordsAcrossAllRange = computed<{ const getDayIndex = (date: dayjs.Dayjs) => {
records: Array<Row> let dayIndex = date.day() - 1
count: { if (dayIndex === -1) {
[key: string]: { dayIndex = 6
[key: string]: {
id: Array<string>
overflow: boolean
overflowCount: number
} }
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 getGridTimeSlots = (from: dayjs.Dayjs, to: dayjs.Dayjs) => {
const scheduleEnd = dayjs(selectedDateRange.value.end).endOf('day') return {
from: getGridTime(from, false),
// 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 to: getGridTime(to, true) - 1,
// 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 dayIndex: getDayIndex(from),
// 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
} }
} }
} = {}
let recordsToDisplay: Array<Row> = [] const hasSlotForRecord = (
columnArray: Row[],
dates: {
fromDate: dayjs.Dayjs
toDate: dayjs.Dayjs
},
) => {
const { fromDate, toDate } = dates
calendarRange.value.forEach((range) => { if (!fromDate || !toDate) return false
const fromCol = range.fk_from_col
const toCol = range.fk_to_col
// We fetch all the records that match the calendar ranges in a single time. for (const column of columnArray) {
// But not all fetched records are valid for the certain range, so we filter them out & sort them const columnFromCol = column.rowMeta.range?.fk_from_col
const sortedFormattedData = [...formattedData.value].filter((record) => { const columnToCol = column.rowMeta.range?.fk_to_col
const fromDate = record.row[fromCol!.title!] ? dayjs(record.row[fromCol!.title!]) : null
if (fromCol && toCol) { if (!columnFromCol) return false
const fromDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null
const toDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null
return fromDate && toDate && !toDate.isBefore(fromDate) const { startDate: columnFromDate, endDate: columnToDate } = calculateNewDates({
} else if (fromCol && !toCol) { startDate: dayjs(column.row[columnFromCol.title!]),
return !!fromDate endDate: columnToCol
} ? dayjs(column.row[columnToCol.title!])
return false : 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 (
if (!toCol && fromCol) { fromDate.isBetween(columnFromDate, columnToDate, null, '[]') ||
// If there is no toColumn chosen in the range toDate.isBetween(columnFromDate, columnToDate, null, '[]')
const ogStartDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null ) {
if (!ogStartDate) return return false
}
let endDate = ogStartDate.clone().add(1, 'hour') }
return true
if (endDate.isAfter(scheduleEnd, 'minutes')) {
endDate = scheduleEnd
} }
const id = record.rowMeta.id ?? generateRandomNumber() const getMaxOverlaps = ({
row,
let startDate = ogStartDate.clone() 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 dayIndex = row.rowMeta.dayIndex
const dateKey = startDate?.format('YYYY-MM-DD') const overlapIndex = columnArray[dayIndex].findIndex((column) => column.findIndex((r) => r.rowMeta.id === id) !== -1) + 1
const hourKey = startDate?.format('HH:mm')
// If the dateKey and hourKey are valid, we add the id to the overlaps object const dfs = (id: string): number => {
if (dateKey && hourKey) { visited.add(id)
if (!overlaps[dateKey]) { let maxOverlaps = 1
overlaps[dateKey] = {} 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 let maxOverlaps = 1
// We also keep track of the number of records that overflow if (graph.has(id)) {
if (overlaps[dateKey][hourKey].id.length > 4) { maxOverlaps = dfs(id)
overlaps[dateKey][hourKey].overflow = true
style.display = 'none'
overlaps[dateKey][hourKey].overflowCount += 1
} }
return { maxOverlaps, dayIndex, overlapIndex }
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = dayjs(dateKey).day() - 1
if (dayIndex === -1) {
dayIndex = 6
} }
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) { if (!formattedData.value || !calendarRange.value || !container.value || !scrollContainer.value)
dayIndex = 6 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 = { const columnArray: Array<Array<Array<Row>>> = [[[]]]
...style, const gridTimeMap = new Map<
top: `${minutes + 1}px`, number,
height: `${perHeight - 2}px`, Map<
number,
{
count: number
id: string[]
} }
>
>()
const recordsToDisplay: Array<Row> = []
recordsToDisplay.push({ calendarRange.value.forEach((range) => {
...record, const fromCol = range.fk_from_col
rowMeta: { const toCol = range.fk_to_col
...record.rowMeta,
id,
position: 'rounded',
style,
range,
dayIndex,
},
})
} else if (fromCol && toCol) {
const id = record.rowMeta.id ?? generateRandomNumber()
let startDate = record.row[fromCol.title!] ? dayjs(record.row[fromCol.title!]) : null // We fetch all the records that match the calendar ranges in a single time.
let endDate = record.row[toCol.title!] ? dayjs(record.row[toCol.title!]) : null // 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 (fromCol && toCol) {
if (!startDate?.isValid()) return 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 return fromDate && toDate && !toDate.isBefore(fromDate)
if (!endDate?.isValid()) { } else if (fromCol && !toCol) {
endDate = startDate.clone().add(30, 'minutes') 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 for (const record of sortedFormattedData) {
// If the end date is after the end of the schedule, we set it to the end of the schedule const id = record.rowMeta.id ?? generateRandomNumber()
// This is to ensure that the records are within the bounds of the schedule and do not overflow
if (startDate.isBefore(scheduleStart, 'minutes')) { if (fromCol && toCol) {
startDate = scheduleStart const { startDate, endDate } = calculateNewDates({
} startDate: dayjs(record.row[fromCol.title!]),
if (endDate.isAfter(scheduleEnd, 'minutes')) { endDate: dayjs(record.row[toCol.title!]),
endDate = scheduleEnd scheduleStart,
} scheduleEnd,
})
// Setting the current start date to the start date of the record // Setting the current start date to the start date of the record
let currentStartDate: dayjs.Dayjs = startDate.clone() let currentStartDate: dayjs.Dayjs = startDate.clone()
@ -242,14 +289,7 @@ const recordsAcrossAllRange = computed<{
const recordStart: dayjs.Dayjs = currentEndDate.isSame(startDate, 'day') ? startDate : currentStartDate const recordStart: dayjs.Dayjs = currentEndDate.isSame(startDate, 'day') ? startDate : currentStartDate
const recordEnd = currentEndDate.isSame(endDate, 'day') ? endDate : currentEndDate const recordEnd = currentEndDate.isSame(endDate, 'day') ? endDate : currentEndDate
const dateKey = recordStart.format('YYYY-MM-DD') const dayIndex = getDayIndex(recordStart)
// TODO: dayIndex is not calculated perfectly
// Should revisit this part in next iteration
let dayIndex = recordStart.day() - 1
if (dayIndex === -1) {
dayIndex = 6
}
// We calculate the index of the start and end hour in the day // We calculate the index of the start and end hour in the day
const startHourIndex = Math.max( const startHourIndex = Math.max(
@ -278,36 +318,8 @@ const recordsAcrossAllRange = computed<{
position = 'none' position = 'none'
} }
let _startHourIndex = startHourIndex
let style: Partial<CSSStyleDeclaration> = {} 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 spanHours = endHourIndex - startHourIndex + 1
const top = startHourIndex * perHeight const top = startHourIndex * perHeight
@ -334,45 +346,170 @@ const recordsAcrossAllRange = computed<{
// We set the current start date to the next day // We set the current start date to the next day
currentStartDate = currentStartDate.add(1, 'day').hour(0).minute(0) 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 for (const record of recordsToDisplay) {
// Hence the first iteration is to find the overlaps, top, height and then the second iteration is to find the left and width const fromCol = record.rowMeta.range?.fk_from_col
// This is because the left and width of the record depends on the overlaps const toCol = record.rowMeta.range?.fk_to_col
recordsToDisplay = recordsToDisplay.map((record) => { if (!fromCol) continue
// maxOverlaps is the maximum number of records that overlap in a single hour const { startDate, endDate } = calculateNewDates({
// overlapIndex is the index of the record in the overlaps object startDate: dayjs(record.row[fromCol.title!]),
let maxOverlaps = 1 endDate: toCol ? dayjs(record.row[toCol.title!]) : dayjs(record.row[fromCol.title!]).add(1, 'hour').subtract(1, 'minute'),
let overlapIndex = 0 scheduleStart,
const dayIndex = record.rowMeta.dayIndex as number 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 dayIndex in columnArray) {
for (const hours in overlaps[dateKey]) { for (const columnIndex in columnArray[dayIndex]) {
// We are checking if the overlaps object contains the id of the record for (const record of columnArray[dayIndex][columnIndex]) {
// If it does, we set the maxOverlaps and overlapIndex record.rowMeta.overLapIteration = parseInt(columnIndex) + 1
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!))
} }
} }
const spacing = 0.1 }
const widthPerRecord = (100 - spacing * (maxOverlaps - 1)) / maxOverlaps / 7 for (const record of recordsToDisplay) {
const leftPerRecord = widthPerRecord * overlapIndex 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 = {
...record.rowMeta.style, ...record.rowMeta.style,
left: `calc(${dayIndex * perWidth}px + ${leftPerRecord}% )`, left: `calc(${majorLeft}px + ${left}%)`,
width: `calc(${widthPerRecord - 0.1}%)`, width: `calc(${width}%)`,
display,
}
} }
return record
})
}) })
return { return {
records: recordsToDisplay, records: recordsToDisplay,
count: overlaps, gridTimeMap,
} }
}) })
@ -497,9 +634,11 @@ const onResizeStart = (direction: 'right' | 'left', event: MouseEvent, record: R
const calculateNewRow = ( const calculateNewRow = (
event: MouseEvent, event: MouseEvent,
updateSideBar?: boolean, updateSideBar?: boolean,
skipChangeCheck?: boolean,
): { ): {
newRow: Row | null newRow: Row | null
updatedProperty: string[] updatedProperty: string[]
skipChangeCheck?: boolean
} => { } => {
const { width, left, top } = container.value.getBoundingClientRect() const { width, left, top } = container.value.getBoundingClientRect()
@ -528,7 +667,7 @@ const calculateNewRow = (
...dragRecord.value, ...dragRecord.value,
row: { row: {
...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() 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!) 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!) const newPk = extractPkFromRow(newRow.row, meta.value!.columns!)
@ -565,6 +709,10 @@ const calculateNewRow = (
const pk = extractPkFromRow(r.row, meta.value!.columns!) const pk = extractPkFromRow(r.row, meta.value!.columns!)
return pk === newPk ? newRow : r return pk === newPk ? newRow : r
}) })
dragRecord.value = {
...dragRecord.value,
row: newRow.row,
}
} }
return { newRow, updatedProperty } return { newRow, updatedProperty }
@ -591,7 +739,7 @@ const stopDrag = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
clearTimeout(dragTimeout.value!) 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 // We set the visibility and opacity of the records back to normal
const allRecords = document.querySelectorAll('.draggable-record') const allRecords = document.querySelectorAll('.draggable-record')
@ -676,33 +824,19 @@ const viewMore = (hour: dayjs.Dayjs) => {
} }
const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => { const isOverflowAcrossHourRange = (hour: dayjs.Dayjs) => {
let startOfHour = hour.startOf('hour') if (!recordsAcrossAllRange.value || !recordsAcrossAllRange.value.gridTimeMap) return { isOverflow: false, overflowCount: 0 }
const endOfHour = hour.endOf('hour') const { gridTimeMap } = recordsAcrossAllRange.value
const dayIndex = getDayIndex(hour)
const ids: Array<string> = [] const startMinute = hour.hour() * 60 + hour.minute()
const endMinute = hour.hour() * 60 + hour.minute() + 59
let isOverflow = false
let overflowCount = 0 let overflowCount = 0
while (startOfHour.isBefore(endOfHour, 'minute')) { for (let minute = startMinute; minute <= endMinute; minute++) {
const dateKey = startOfHour.format('YYYY-MM-DD') const recordCount = gridTimeMap.get(dayIndex)?.get(minute)?.count ?? 0
const hourKey = startOfHour.format('HH:mm') overflowCount = Math.max(overflowCount, recordCount)
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')
} }
overflowCount = overflowCount > 4 ? overflowCount - 4 : 0 return { isOverflow: overflowCount - 3 > 0, overflowCount: overflowCount - 3 }
return { isOverflow, overflowCount }
} }
// TODO: Add Support for multiple ranges when multiple ranges are supported // TODO: Add Support for multiple ranges when multiple ranges are supported
@ -773,7 +907,6 @@ watch(
@click=" @click="
() => { () => {
selectedTime = hour selectedTime = hour
selectedDate = hour
dragRecord = undefined 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) }} {{ log.description.substring(log.description.indexOf(':') + 1) }}
</div> </div>
<div v-if="log.id === editLog?.id" class="flex justify-end gap-1"> <div v-if="log.id === editLog?.id" class="flex justify-end gap-1">
<NcButton type="secondary" size="sm" @click="onCancel"> Cancel </NcButton> <NcButton size="small" type="secondary" @click="onCancel"> Cancel </NcButton>
<NcButton v-e="['a:row-expand:comment:save']" size="sm" @click="onEditComment"> Save </NcButton> <NcButton v-e="['a:row-expand:comment:save']" size="small" @click="onEditComment"> Save </NcButton>
</div> </div>
</div> </div>
</div> </div>

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

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

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

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

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

@ -9,7 +9,7 @@ export const useColumnDrag = ({
tableBodyEl: Ref<HTMLElement | undefined> tableBodyEl: Ref<HTMLElement | undefined>
gridWrapper: Ref<HTMLElement | undefined> gridWrapper: Ref<HTMLElement | undefined>
}) => { }) => {
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus, isDefaultView, meta } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo() const { addUndo, defineViewScope } = useUndoRedo()
const { activeView } = storeToRefs(useViewsStore()) const { activeView } = storeToRefs(useViewsStore())
@ -22,6 +22,24 @@ export const useColumnDrag = ({
const dragColPlaceholderDomRef = ref<HTMLElement | null>(null) const dragColPlaceholderDomRef = ref<HTMLElement | null>(null)
const toBeDroppedColId = ref<string | 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 reorderColumn = async (colId: string, toColId: string) => {
const toBeReorderedViewCol = gridViewCols.value[colId] const toBeReorderedViewCol = gridViewCols.value[colId]
@ -46,12 +64,19 @@ export const useColumnDrag = ({
toBeReorderedViewCol.order = newOrder toBeReorderedViewCol.order = newOrder
if (isDefaultView.value && toBeReorderedViewCol.fk_column_id) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, newOrder)
}
addUndo({ addUndo({
undo: { undo: {
fn: async () => { fn: async () => {
if (!fields.value) return if (!fields.value) return
toBeReorderedViewCol.order = oldOrder toBeReorderedViewCol.order = oldOrder
if (isDefaultView.value) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, oldOrder)
}
await updateGridViewColumn(colId, { order: oldOrder } as any) await updateGridViewColumn(colId, { order: oldOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD) eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
@ -63,6 +88,9 @@ export const useColumnDrag = ({
if (!fields.value) return if (!fields.value) return
toBeReorderedViewCol.order = newOrder toBeReorderedViewCol.order = newOrder
if (isDefaultView.value) {
updateDefaultViewColumnOrder(toBeReorderedViewCol.fk_column_id, newOrder)
}
await updateGridViewColumn(colId, { order: newOrder } as any) await updateGridViewColumn(colId, { order: newOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD) 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 { isParentOpen, columns } = toRefs(props)
const activeView = inject(ActiveViewInj, ref())
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const { showSystemFields, metaColumnById } = useViewColumnsOrThrow() const { showSystemFields, metaColumnById } = useViewColumnsOrThrow()

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

@ -54,7 +54,7 @@ const {
toggleFieldVisibility, toggleFieldVisibility,
} = useViewColumnsOrThrow() } = useViewColumnsOrThrow()
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus, isDefaultView } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo() const { addUndo, defineViewScope } = useUndoRedo()
@ -127,7 +127,7 @@ const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } },
fields.value.map(async (field, index) => { fields.value.map(async (field, index) => {
if (field.order !== index + 1) { if (field.order !== index + 1) {
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, provide,
ref, ref,
toRef, toRef,
useExpandedFormDetachedProvider,
useMetas, useMetas,
useProvideCalendarViewStore, useProvideCalendarViewStore,
useProvideKanbanViewStore, useProvideKanbanViewStore,
@ -83,6 +84,7 @@ provide(
ReadonlyInj, ReadonlyInj,
computed(() => !isUIAllowed('dataEdit')), computed(() => !isUIAllowed('dataEdit')),
) )
useExpandedFormDetachedProvider()
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger()) useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere) 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 listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow() const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } = const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
@ -47,8 +49,6 @@ const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, re
await loadRelatedTableMeta() await loadRelatedTableMeta()
const addIcon = computed(() => (cellValue?.value ? 'expand' : 'plus'))
const value = computed(() => { const value = computed(() => {
if (cellValue?.value) { if (cellValue?.value) {
return 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, 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], () => { watch(value, (next) => {
if (!listItemsDlg.value) { if (next) {
plusBtnRef.value?.focus() isOpen.value = false
} }
}) })
</script> </script>
<template> <template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }"> <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"> <div class="nc-cell-field chips flex items-center flex-1">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)"> <template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip <VirtualCellComponentsItemChip
@ -109,24 +124,26 @@ watch([listItemsDlg], () => {
<div <div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup" v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
ref="plusBtnRef" class="flex-none flex group items-center min-w-4"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0" tabindex="0"
@keydown.enter.stop="listItemsDlg = true" @keydown.enter.stop="listItemsDlg = true"
> >
<GeneralIcon <GeneralIcon
:icon="addIcon" icon="plus"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible" 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" @click.stop="listItemsDlg = true"
/> />
</div> </div>
</div>
<template #overlay>
<LazyVirtualCellComponentsUnLinkedItems <LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg" v-if="listItemsDlg"
v-model="listItemsDlg" v-model="listItemsDlg"
:column="belongsToColumn" :column="belongsToColumn"
@attach-record="listItemsDlg = true" hide-back-btn
/> /> </template
></LazyVirtualCellComponentsLinkRecordDropdown>
</div> </div>
</template> </template>

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

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

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

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

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

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

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

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

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

@ -1,20 +1,22 @@
<script lang="ts" setup> <script lang="ts" setup>
import OnetoOneIcon from '~icons/nc-icons/onetoone' 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' import { iconMap } from '#imports'
const { relation, relatedTableTitle, displayValue, header, tableTitle } = defineProps<{ const {
relation,
relatedTableTitle,
tableTitle,
linkedRecords = 0,
} = defineProps<{
relation: string relation: string
header?: string | null header?: string | null
tableTitle: string tableTitle: string
relatedTableTitle: string relatedTableTitle: string
displayValue?: string displayValue?: string
linkedRecords?: number
}>() }>()
const { isMobileMode } = useGlobal()
const { t } = useI18n() const { t } = useI18n()
const relationMeta = computed(() => { const relationMeta = computed(() => {
@ -52,56 +54,17 @@ const relationMeta = computed(() => {
</script> </script>
<template> <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 <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="{ :class="{
'!bg-orange-50 !text-orange-500': relation === 'hm', 'bg-gray-200 text-gray-600': !linkedRecords,
'!bg-pink-50 !text-pink-500': relation === 'mm', 'bg-orange-100 text-orange-700': relation === 'hm' && linkedRecords,
'!bg-blue-50 !text-blue-500': relation === 'bt', '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 <NcTooltip class="z-10 flex" placement="bottom">
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">
<template #title> <template #title>
<div class="p-1"> <div class="p-1">
<h1 class="text-white font-bold">{{ relationMeta.title }}</h1> <h1 class="text-white font-bold">{{ relationMeta.title }}</h1>
@ -117,8 +80,20 @@ const relationMeta = computed(() => {
</div> </div>
</div> </div>
</template> </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> </NcTooltip>
<div class="leading-[20px]">
{{ linkedRecords || 0 }} {{ $t('general.linked') }}
{{ linkedRecords === 1 ? $t('objects.record') : $t('objects.records') }}
</div> </div>
</div> </div>
</template> </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> <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 { import {
ColumnInj, ColumnInj,
IsFormInj, IsFormInj,
@ -30,10 +31,14 @@ const vModel = useVModel(props, 'modelValue', emit)
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const { t } = useI18n()
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const isExpandedFormCloseAfterSave = ref(false)
const injectedColumn = inject(ColumnInj, ref()) const injectedColumn = inject(ColumnInj, ref())
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
@ -58,7 +63,7 @@ const {
relatedTableMeta, relatedTableMeta,
link, link,
meta, meta,
headerDisplayValue, row,
resetChildrenListOffsetCount, resetChildrenListOffsetCount,
} = useLTARStoreOrThrow() } = useLTARStoreOrThrow()
@ -68,7 +73,7 @@ watch(
[vModel, isForm], [vModel, isForm],
(nextVal) => { (nextVal) => {
if ((nextVal[0] || nextVal[1]) && !isNew.value) { if ((nextVal[0] || nextVal[1]) && !isNew.value) {
loadChildrenList() loadChildrenList(true)
} }
// reset offset count when closing modal // reset offset count when closing modal
@ -102,20 +107,96 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => { const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? []) return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col)) .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 expandedFormDlg = ref(false)
const expandedFormRow = ref({}) 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 colTitle = computed(() => injectedColumn.value?.title || '')
const onClick = (row: Row) => { const onClick = (row: Row) => {
if (readOnly.value) return if (readOnly.value || isForm.value) return
expandedFormRow.value = row expandedFormRow.value = row
expandedFormDlg.value = true 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(() => { const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type return injectedColumn!.value?.colOptions?.type
@ -129,6 +210,9 @@ watch(
) )
watch(expandedFormDlg, () => { watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
}
childrenExcludedOffsetCount.value = 0 childrenExcludedOffsetCount.value = 0
childrenListOffsetCount.value = 0 childrenListOffsetCount.value = 0
}) })
@ -154,6 +238,10 @@ const skeletonCount = computed(() => {
}) })
const totalItemsToShow = computed(() => { const totalItemsToShow = computed(() => {
if (isForm.value || isNew.value) {
return state.value?.[colTitle.value]?.length
}
if (isChildrenLoading.value) { if (isChildrenLoading.value) {
return props.items return props.items
} }
@ -204,6 +292,10 @@ const linkedShortcuts = (e: KeyboardEvent) => {
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', linkedShortcuts) window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
}) })
const childrenListRef = ref<HTMLDivElement>() const childrenListRef = ref<HTMLDivElement>()
@ -226,34 +318,17 @@ const onFilterChange = () => {
</script> </script>
<template> <template>
<NcModal <div class="nc-modal-child-list h-full w-full" :class="{ active: vModel }" @keydown.enter.stop>
v-model:visible="vModel" <div class="flex flex-col h-full">
:body-style="{ 'max-height': '640px', 'height': '85vh' }" <div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
:class="{ active: vModel }" <div v-if="!isForm" class="flex-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
:closable="false" <MdiMagnify class="nc-search-icon w-5 h-5" />
: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" />
<a-input <a-input
ref="filterQueryRef" ref="filterQueryRef"
v-model:value="childrenListPagination.query" v-model:value="childrenListPagination.query"
:bordered="false" :bordered="false"
:placeholder="`Search in ${relatedTableMeta?.title}`" placeholder="Search linked records..."
class="w-full !sm:rounded-md xs:min-h-8 !xs:rounded-xl" class="w-full min-h-4"
size="small" size="small"
@change="onFilterChange" @change="onFilterChange"
@keydown.capture.stop=" @keydown.capture.stop="
@ -266,35 +341,41 @@ const onFilterChange = () => {
> >
</a-input> </a-input>
</div> </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>
<div ref="childrenListRef" class="flex flex-col flex-grow nc-scrollbar-md cursor-pointer pr-1"> <div ref="childrenListRef" class="flex-1 overflow-auto nc-scrollbar-thin">
<div v-if="isDataExist || isChildrenLoading" class="mt-2 mb-2"> <div v-if="isDataExist || isChildrenLoading">
<div class="cursor-pointer pr-1"> <div class="cursor-pointer">
<template v-if="isChildrenLoading"> <template v-if="isChildrenLoading">
<div <div
v-for="(_x, i) in Array.from({ length: skeletonCount })" v-for="(_x, i) in Array.from({ length: skeletonCount })"
:key="i" :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 items-center">
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center"> <a-skeleton-image class="h-14 w-14 !rounded-xl children:!h-full" />
<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> </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"> <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-2 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" /> <a-skeleton-input active class="!h-2 !w-24" size="small" />
</div> </div>
<div class="flex flex-col gap-0.5"> <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-2 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" /> <a-skeleton-input active class="!h-2 !w-24" size="small" />
</div> </div>
<div class="flex flex-col gap-0.5"> <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-2 !w-12" size="small" />
<a-skeleton-input active class="!h-4 !w-24" size="small" /> <a-skeleton-input active class="!h-2 !w-24" size="small" />
</div> </div>
</div> </div>
</div> </div>
@ -312,29 +393,30 @@ const onFilterChange = () => {
:related-table-display-value-prop="relatedTableDisplayValueProp" :related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow" :row="refRow"
data-testid="nc-child-list-item" data-testid="nc-child-list-item"
@click="linkOrUnLink(refRow, id)" @link-or-unlink="linkOrUnLink(refRow, id)"
@expand="onClick(refRow)" @expand="onClick(refRow)"
@keydown.space.prevent="linkOrUnLink(refRow, id)" @keydown.space.prevent.stop="linkOrUnLink(refRow, id)"
@keydown.enter.prevent="() => onClick(refRow, id)" @keydown.enter.prevent.stop="() => onClick(refRow, id)"
/> />
</template> </template>
</div> </div>
</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 <img
:alt="$t('msg.clickLinkRecordsToAddLinkFromTable', { tableName: relatedTableMeta?.title })" :alt="$t('msg.clickLinkRecordsToAddLinkFromTable')"
class="!w-[18.5rem] flex-none" class="!w-[158px] flex-none"
src="~assets/img/placeholder/link-records.png" 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"> <div class="text-gray-700">
{{ $t('msg.clickLinkRecordsToAddLinkFromTable', { tableName: relatedTableMeta?.title }) }} {{ $t('msg.clickLinkRecordsToAddLinkFromTable') }}
</div> </div>
<NcButton <NcButton
v-if="!readOnly && childrenListCount < 1" v-if="!readOnly && (childrenListCount < 1 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
v-e="['c:links:link']" v-e="['c:links:link']"
data-testid="nc-child-list-button-link-to" data-testid="nc-child-list-button-link-to"
size="small"
@click="emit('attachRecord')" @click="emit('attachRecord')"
> >
<div class="flex items-center gap-1"><MdiPlus /> {{ $t('title.linkRecords') }}</div> <div class="flex items-center gap-1"><MdiPlus /> {{ $t('title.linkRecords') }}</div>
@ -342,51 +424,45 @@ const onFilterChange = () => {
</div> </div>
</div> </div>
<div v-if="isMobileMode" class="flex flex-row justify-center items-center w-full my-2"> <div class="bg-gray-100 px-3 py-2 rounded-b-md flex items-center justify-between gap-3 min-h-12">
<NcPagination <div class="flex items-center gap-2">
v-if="!isNew && childrenList?.pageInfo" <NcButton
v-model:current="childrenListPagination.page" v-if="!isPublic"
v-model:page-size="childrenListPagination.size" v-e="['c:row-expand:open']"
:total="+childrenList.pageInfo.totalRows!" 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>
</NcButton>
<div class="my-2 bg-gray-50 border-gray-50 border-b-2"></div> <NcButton
v-if="!readOnly && (childrenListCount > 0 || (childrenList?.list ?? state?.[colTitle] ?? []).length > 0)"
<div class="flex flex-row justify-between bg-white relative pt-1"> v-e="['c:links:link']"
<div v-if="!isForm" class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50"> data-testid="nc-child-list-button-link-to"
{{ totalItemsToShow || 0 }} {{ !isMobileMode ? $t('objects.records') : '' }} class="!hover:(bg-white text-brand-500)"
{{ !isMobileMode && totalItemsToShow !== 0 ? $t('general.are') : '' }} size="small"
{{ $t('general.linked') }} 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>
<div v-else class="flex items-center justify-center px-2 rounded-md text-gray-500 bg-brand-50"> </NcButton>
<span class="">
{{ state?.[colTitle]?.length || 0 }} {{ $t('objects.records') }}
{{ state?.[colTitle]?.length !== 0 ? $t('general.are') : '' }}
{{ $t('general.linked') }}
</span>
</div> </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 <NcPagination
v-if="!isNew && childrenList?.pageInfo"
v-model:current="childrenListPagination.page" v-model:current="childrenListPagination.page"
v-model:page-size="childrenListPagination.size" v-model:page-size="childrenListPagination.size"
:total="+childrenList.pageInfo.totalRows!" :total="+childrenList.pageInfo.totalRows!"
mode="simple" mode="simple"
/> />
</div> </div>
<div class="flex flex-row gap-2"> </template>
<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>
</div> </div>
</div> </div>
@ -394,7 +470,15 @@ const onFilterChange = () => {
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
v-if="expandedFormRow && expandedFormDlg" v-if="expandedFormRow && expandedFormDlg"
v-model="expandedFormDlg" v-model="expandedFormDlg"
:close-after-save="isExpandedFormCloseAfterSave"
:meta="relatedTableMeta" :meta="relatedTableMeta"
:new-record-header="
isExpandedFormCloseAfterSave
? $t('activity.tableNameCreateNewRecord', {
tableName: relatedTableMeta?.title,
})
: undefined
"
:row="{ :row="{
row: expandedFormRow, row: expandedFormRow,
oldRow: expandedFormRow, oldRow: expandedFormRow,
@ -405,11 +489,13 @@ const onFilterChange = () => {
new: true, new: true,
}, },
}" }"
:state="newRowState"
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])" :row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
use-meta-fields use-meta-fields
@created-record="onCreatedRecord"
/> />
</Suspense> </Suspense>
</NcModal> </div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -420,10 +506,22 @@ const onFilterChange = () => {
:deep(.ant-modal-content) { :deep(.ant-modal-content) {
@apply !p-0; @apply !p-0;
} }
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style> </style>
<style lang="scss"> <style lang="scss">
.nc-modal-child-list > .ant-modal > .ant-modal-content { .nc-dropdown-link-record-search-wrapper {
@apply !p-0; .nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
} }
</style> </style>

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

@ -16,9 +16,9 @@ import {
useVModel, useVModel,
} from '#imports' } from '#imports'
import MaximizeIcon from '~icons/nc-icons/maximize' import MaximizeIcon from '~icons/nc-icons/maximize'
import LinkIcon from '~icons/nc-icons/link'
const props = defineProps<{ const props = withDefaults(
defineProps<{
row: any row: any
fields: any[] fields: any[]
attachment: any attachment: any
@ -26,9 +26,13 @@ const props = defineProps<{
displayValueTypeAndFormatProp: { type: string; format: string } displayValueTypeAndFormatProp: { type: string; format: string }
isLoading: boolean isLoading: boolean
isLinked: boolean isLinked: boolean
}>() }>(),
{
isLoading: false,
},
)
defineEmits(['expand']) defineEmits(['expand', 'linkOrUnlink'])
provide(IsExpandedFormOpenInj, ref(true)) provide(IsExpandedFormOpenInj, ref(true))
@ -88,116 +92,198 @@ const displayValue = computed(() => {
</script> </script>
<template> <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 <a-card
tabindex="0" 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="{ :class="{
'!bg-white': isLoading, '!bg-white': isLoading,
'!border-1': isLinked && !isLoading, '!hover:bg-white': readOnly,
'!cursor-auto !hover:bg-white': readOnly,
}" }"
:body-style="{ padding: 0 }" :body-style="{ padding: '6px 10px !important', borderRadius: 0 }"
:hoverable="false" :hoverable="false"
> >
<div class="flex flex-row items-center justify-start w-full"> <div class="flex items-center gap-3">
<a-carousel v-if="attachment && attachments && attachments.length" autoplay class="!w-24 !h-24 !max-h-24 !max-w-24"> <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 #customPaging> </template>
<template v-for="(attachmentObj, index) in attachments"> <template v-for="(attachmentObj, index) in attachments">
<LazyCellAttachmentImage <LazyCellAttachmentImage
v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)" v-if="isImage(attachmentObj.title, attachmentObj.mimetype ?? attachmentObj.type)"
:key="`carousel-${attachmentObj.title}-${index}`" :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)" :srcs="getPossibleAttachmentSrc(attachmentObj)"
/> />
</template> </template>
</a-carousel> </a-carousel>
</div>
<div <div
v-else-if="attachment" v-else
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" 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" /> <GeneralIcon class="w-full h-full !text-6xl !leading-10 !text-transparent rounded-lg" icon="fileImage" />
</div> </div>
</template>
<div class="flex flex-col m-[.75rem] gap-1 flex-grow justify-center overflow-hidden"> <div class="flex-1 flex flex-col gap-1 justify-center overflow-hidden">
<div class="flex justify-between xs:gap-x-2"> <div class="flex justify-start">
<span class="font-semibold text-brand-500 nc-display-value xs:(truncate)"> <span class="font-semibold text-brand-500 nc-display-value truncate leading-[20px]">
{{ displayValue }} {{ displayValue }}
</span> </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>
<div <div
v-if="fields.length > 0 && !isPublic && !isForm" 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 v-for="field in fields" :key="field.id" class="sm:(w-1/3 max-w-1/3 overflow-hidden)">
<div class="flex flex-col gap-[-1] max-w-72"> <div v-if="!isRowEmpty(row, field)" class="flex flex-col gap-[-1]">
<NcTooltip class="z-10 flex" placement="bottom">
<template #title>
<LazySmartsheetHeaderVirtualCell <LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
class="!scale-60" class="!scale-60 text-gray-100 !text-sm"
:column="field" :column="field"
:hide-menu="true" :hide-menu="true"
:hide-icon="true"
/> />
<LazySmartsheetHeaderCell v-else class="!scale-70" :column="field" :hide-menu="true" :hide-icon="true" /> <LazySmartsheetHeaderCell v-else class="!scale-70 text-gray-100 !text-sm" :column="field" :hide-menu="true" />
</template>
<div v-if="!isRowEmpty(row, field)"> <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" /> <LazySmartsheetVirtualCell v-if="isVirtualCol(field)" v-model="row[field.title]" :row="row" :column="field" />
<LazySmartsheetCell <LazySmartsheetCell
v-else v-else
v-model="row[field.title]" v-model="row[field.title]"
class="!text-gray-600 ml-1"
:column="field" :column="field"
:edit-enabled="false" :edit-enabled="false"
:read-only="true" :read-only="true"
/> />
</div> </div>
<div v-else class="flex flex-row w-full h-[1.375rem] pl-1 items-center justify-start">-</div> </NcTooltip>
</div>
</div> </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> </div>
</div> </div>
<NcButton <div v-if="!isForm && !isPublic && !readOnly" class="flex-none flex items-center w-7">
v-if="!isForm && !isPublic && !readOnly" <button
v-e="['c:row-expand:open']" v-e="['c:row-expand:open']"
type="text" :tabindex="-1"
size="medium" 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)"
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,
}"
@click.stop="$emit('expand', row)" @click.stop="$emit('expand', row)"
> >
<MaximizeIcon class="w-4 h-4" /> <MaximizeIcon class="flex-none w-4 h-4 scale-125" />
</NcButton> </button>
</div>
</div>
</a-card> </a-card>
</div>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.slick-list) { :deep(.slick-list) {
@apply rounded-lg; @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>
<style lang="scss"> <style lang="scss">
.nc-list-item { .nc-list-item {
@apply border-1 border-transparent rounded-md;
&:focus-visible {
@apply border-brand-500;
box-shadow: 0 0 0 1px #3366ff;
}
&:hover { &:hover {
.nc-text-area-expand-btn { .nc-text-area-expand-btn {
@apply !hidden; @apply !hidden;
@ -206,13 +292,14 @@ const displayValue = computed(() => {
.long-text-wrapper { .long-text-wrapper {
@apply select-none pointer-events-none; @apply select-none pointer-events-none;
.nc-readonly-rich-text-wrapper { .nc-readonly-rich-text-wrapper {
@apply !min-h-6 !max-h-6; @apply !min-h-5 !max-h-5;
} }
.nc-rich-text-embed { .nc-rich-text-embed {
@apply -mt-0.5;
.nc-textarea-rich-editor { .nc-textarea-rich-editor {
@apply !overflow-hidden; @apply !overflow-hidden;
.ProseMirror { .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, useVModel,
} from '#imports' } 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) const vModel = useVModel(props, 'modelValue', emit)
@ -50,7 +50,6 @@ const {
meta, meta,
unlink, unlink,
row, row,
headerDisplayValue,
resetChildrenExcludedOffsetCount, resetChildrenExcludedOffsetCount,
} = useLTARStoreOrThrow() } = useLTARStoreOrThrow()
@ -66,6 +65,10 @@ const isForm = inject(IsFormInj, ref(false))
const saveRow = inject(SaveRowInj, () => {}) const saveRow = inject(SaveRowInj, () => {})
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const reloadViewDataTrigger = inject(ReloadViewDataHookInj, createEventHook())
const linkRow = async (row: Record<string, any>, id: number) => { const linkRow = async (row: Record<string, any>, id: number) => {
if (isNew.value) { if (isNew.value) {
addLTARRef(row, injectedColumn?.value as ColumnType) addLTARRef(row, injectedColumn?.value as ColumnType)
@ -100,7 +103,7 @@ watch(
if (!isForm.value) { if (!isForm.value) {
loadChildrenList() loadChildrenList()
} }
loadChildrenExcludedList(rowState.value) loadChildrenExcludedList(rowState.value, true)
} }
if (!nextVal) { if (!nextVal) {
resetChildrenExcludedOffsetCount() resetChildrenExcludedOffsetCount()
@ -157,13 +160,31 @@ const attachmentCol = computedInject(FieldsInj, (_fields) => {
const fields = computedInject(FieldsInj, (_fields) => { const fields = computedInject(FieldsInj, (_fields) => {
return (relatedTableMeta.value.columns ?? []) return (relatedTableMeta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !isPrimary(col) && !isLinksOrLTAR(col) && !isAttachment(col)) .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(() => { const relation = computed(() => {
return injectedColumn!.value?.colOptions?.type 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, () => { watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) { if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false isExpandedFormCloseAfterSave.value = false
@ -196,6 +217,15 @@ const addNewRecord = () => {
} }
const onCreatedRecord = (record: any) => { const onCreatedRecord = (record: any) => {
addLTARRef(record, injectedColumn?.value as ColumnType)
reloadTrigger?.trigger({
shouldShowLoading: false,
})
reloadViewDataTrigger?.trigger({
shouldShowLoading: false,
})
const msgVNode = h( const msgVNode = h(
'div', 'div',
{ {
@ -223,6 +253,8 @@ const onCreatedRecord = (record: any) => {
) )
message.success(msgVNode) message.success(msgVNode)
vModel.value = false
} }
const linkedShortcuts = (e: KeyboardEvent) => { const linkedShortcuts = (e: KeyboardEvent) => {
@ -253,6 +285,10 @@ watch(childrenExcludedListPagination, () => {
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', linkedShortcuts) window.addEventListener('keydown', linkedShortcuts)
setTimeout(() => {
filterQueryRef.value?.focus()
}, 100)
}) })
onUnmounted(() => { onUnmounted(() => {
@ -268,32 +304,26 @@ const onFilterChange = () => {
</script> </script>
<template> <template>
<NcModal <div class="nc-modal-link-record h-full w-full overflow-hidden" :class="{ active: vModel }" @keydown.enter.stop>
v-model:visible="vModel" <div class="flex flex-col h-full">
:body-style="{ 'max-height': '640px', 'height': '85vh' }" <div class="nc-dropdown-link-record-header bg-gray-100 py-2 rounded-t-md flex justify-between pl-3 pr-2 gap-2">
:class="{ active: vModel }" <div class="flex-1 gap-2 flex items-center">
:closable="false" <button
:footer="null" v-if="!hideBackBtn"
:width="isForm ? 600 : 800" class="!text-brand-500 hover:!text-brand-700 p-1.5 flex"
wrap-class-name="nc-modal-link-record" @click="emit('attachLinkedRecord')"
> >
<LazyVirtualCellComponentsHeader <GeneralIcon icon="ncArrowLeft" class="flex-none h-4 w-4" />
v-if="!isForm" </button>
:display-value="headerDisplayValue"
:header="$t('activity.addNewLink')" <div class="flex-1 nc-dropdown-link-record-search-wrapper flex items-center py-0.5 rounded-md">
:related-table-title="relatedTableMeta?.title" <MdiMagnify class="nc-search-icon w-5 h-5" />
: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" />
<a-input <a-input
ref="filterQueryRef" ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query" v-model:value="childrenExcludedListPagination.query"
:bordered="false" :bordered="false"
:placeholder="`${$t('general.searchIn')} ${relatedTableMeta?.title}`" placeholder="Search records to link..."
class="w-full !rounded-md nc-excluded-search xs:min-h-8" class="w-full nc-excluded-search min-h-4"
size="small" size="small"
@change="onFilterChange" @change="onFilterChange"
@keydown.capture.stop=" @keydown.capture.stop="
@ -306,49 +336,41 @@ const onFilterChange = () => {
> >
</a-input> </a-input>
</div> </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> </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"> <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"> <template v-if="isChildrenExcludedLoading">
<div <div
v-for="(_x, i) in Array.from({ length: 10 })" v-for="(_x, i) in Array.from({ length: 10 })"
:key="i" :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 items-center">
<div class="flex flex-col m-[.5rem] gap-2 flex-grow justify-center"> <a-skeleton-image class="h-14 w-14 !rounded-xl children:!h-full" />
<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> </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"> <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-2 !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-24" size="small" />
</div> </div>
<div class="flex flex-col gap-0.5"> <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-2 !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-24" size="small" />
</div> </div>
<div class="flex flex-col gap-0.5"> <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-2 !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-24" size="small" />
</div> </div>
</div> </div>
</div> </div>
@ -366,48 +388,55 @@ const onFilterChange = () => {
:related-table-display-value-prop="relatedTableDisplayValueProp" :related-table-display-value-prop="relatedTableDisplayValueProp"
:row="refRow" :row="refRow"
data-testid="nc-excluded-list-item" data-testid="nc-excluded-list-item"
@click="() => onClick(refRow, id)" @link-or-unlink="onClick(refRow, id)"
@expand=" @expand="
() => { () => {
expandedFormRow = refRow expandedFormRow = refRow
expandedFormDlg = true expandedFormDlg = true
} }
" "
@keydown.space.prevent="() => onClick(refRow, id)" @keydown.space.prevent.stop="() => onClick(refRow, id)"
@keydown.enter.prevent="() => onClick(refRow, id)" @keydown.enter.prevent.stop="() => onClick(refRow, id)"
/> />
</template> </template>
</div> </div>
</template> </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" /> <InboxIcon class="w-16 h-16 mx-auto" />
<p> <p>
{{ $t('msg.thereAreNoRecordsInTable') }} {{ $t('msg.thereAreNoRecordsInTable') }}
{{ relatedTableMeta?.title }} {{ relatedTableMeta?.title }}
</p> </p>
</div> </div>
</div>
<div v-if="isMobileMode" class="flex flex-row justify-center items-center w-full my-2"> <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 <NcPagination
v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page" v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size" v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows" :total="+childrenExcludedList?.pageInfo?.totalRows"
entity-name="links-excluded-list" entity-name="links-excluded-list"
/> />
</div> </div>
<div v-else class="flex items-center">
<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">
<NcPagination <NcPagination
v-if="childrenExcludedList?.pageInfo"
v-model:current="childrenExcludedListPagination.page" v-model:current="childrenExcludedListPagination.page"
v-model:page-size="childrenExcludedListPagination.size" v-model:page-size="childrenExcludedListPagination.size"
:total="+childrenExcludedList?.pageInfo?.totalRows" :total="+childrenExcludedList?.pageInfo?.totalRows"
@ -415,7 +444,8 @@ const onFilterChange = () => {
mode="simple" mode="simple"
/> />
</div> </div>
<NcButton class="nc-close-btn ml-auto" type="ghost" @click="vModel = false"> {{ $t('general.finish') }} </NcButton> </template>
</div>
</div> </div>
<Suspense> <Suspense>
<LazySmartsheetExpandedForm <LazySmartsheetExpandedForm
@ -443,14 +473,29 @@ const onFilterChange = () => {
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])" :row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
:state="newRowState" :state="newRowState"
use-meta-fields use-meta-fields
:skip-reload="true"
@created-record="onCreatedRecord" @created-record="onCreatedRecord"
/> />
</Suspense> </Suspense>
</NcModal> </div>
</template> </template>
<style lang="scss" scoped>
:deep(.ant-skeleton-element .ant-skeleton-image) {
@apply !h-full;
}
</style>
<style lang="scss"> <style lang="scss">
.nc-modal-link-record > .ant-modal > .ant-modal-content { .nc-dropdown-link-record-search-wrapper {
@apply !p-0; .nc-search-icon {
@apply flex-none text-gray-500;
}
&:focus-within {
.nc-search-icon {
@apply text-gray-600;
}
}
} }
</style> </style>

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

@ -1,6 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { OrderedWorkspaceRoles, WorkspaceUserRoles, parseStringDateTime, timeAgo } from 'nocodb-sdk' import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { storeToRefs, useUserSorts, useWorkspace } from '#imports' import { IsAdminPanelInj, storeToRefs, useUserSorts, useWorkspace } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const { workspaceRoles, loadRoles } = useRoles() const { workspaceRoles, loadRoles } = useRoles()
@ -8,12 +12,22 @@ const workspaceStore = useWorkspace()
const { removeCollaborator, updateCollaborator: _updateCollaborator } = workspaceStore 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 { sorts, sortDirection, loadSorts, saveOrUpdate, handleGetSortedData } = useUserSorts('Workspace')
const userSearchText = ref('') const userSearchText = ref('')
const isAdminPanel = inject(IsAdminPanelInj, ref(false))
const { isUIAllowed } = useRoles()
const inviteDlg = ref(false)
const filterCollaborators = computed(() => { const filterCollaborators = computed(() => {
if (!userSearchText.value) return collaborators.value ?? [] 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(() => { const sortedCollaborators = computed(() => {
return handleGetSortedData(filterCollaborators.value, sorts.value) 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) => { const updateCollaborator = async (collab: any, roles: WorkspaceUserRoles) => {
try { try {
await _updateCollaborator(collab.id, roles) console.log()
await _updateCollaborator(collab.id, roles, currentWorkspace.value.id)
message.success('Successfully updated user role') message.success('Successfully updated user role')
collaborators.value?.forEach((collaborator) => { collaborators.value?.forEach((collaborator) => {
@ -54,81 +89,89 @@ const accessibleRoles = computed<WorkspaceUserRoles[]>(() => {
}) })
onMounted(async () => { onMounted(async () => {
await loadRoles() await loadRoles(null, {}, currentWorkspace.value?.id)
loadSorts() loadSorts()
}) })
</script> </script>
<template> <template>
<div class="nc-collaborator-table-container mt-4 mx-6 h-[calc(100vh-12rem)]"> <DlgInviteDlg v-model:model-value="inviteDlg" :workspace-id="currentWorkspace.id" type="workspace" />
<div class="w-full flex justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2"> <div class="nc-collaborator-table-container mt-4 h-[calc(100vh-10rem)]">
<div class="text-xl">Invite Members By Email</div> <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"> <a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members">
<template #prefix> <template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" /> <PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template> </template>
</a-input> </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> </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"> <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" /> <a-empty description="No members found" />
</div> </div>
<div v-else class="nc-collaborators-list mt-6 h-full"> <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-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-12 items-center"> <div class="flex flex-row bg-gray-50 min-h-11 items-center border-b-1">
<div class="text-gray-700 users-email-grid w-3/8 ml-10 mr-3 flex items-center space-x-2"> <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> <span>
{{ $t('objects.users') }} {{ $t('objects.users') }}
</span> </span>
<LazyAccountUserMenu :direction="sortDirection.email" field="email" :handle-user-sort="saveOrUpdate" /> <LazyAccountUserMenu :direction="sortDirection.email" :handle-user-sort="saveOrUpdate" field="email" />
</div> </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> <span>
{{ $t('general.access') }} {{ $t('general.access') }}
</span> </span>
<LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" /> <LazyAccountUserMenu :direction="sortDirection.roles" field="roles" :handle-user-sort="saveOrUpdate" />
</div> </div>
<div class="text-gray-700 date-joined-grid w-2/8 mr-3">{{ $t('title.dateJoined') }}</div> <div class="text-gray-700 w-full flex-1 px-6 py-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 text-right flex-1 px-6 py-3">{{ $t('labels.actions') }}</div>
</div> </div>
<div class="flex flex-col nc-scrollbar-md"> <div class="flex flex-col nc-scrollbar-md">
<div <div
v-for="(collab, i) of sortedCollaborators" v-for="(collab, i) of sortedCollaborators"
:key="i" :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"> <div class="py-3 px-6">
<GeneralUserIcon size="base" :name="collab.email" :email="collab.email" /> <NcCheckbox v-model:checked="selected[i]" />
<NcTooltip v-if="collab.display_name"> </div>
<template #title>
{{ collab.email }} <div class="flex gap-3 w-[30rem] items-center users-email-grid">
</template> <GeneralUserIcon :email="collab.email" size="base" />
<span class="truncate"> <div class="flex flex-col">
{{ collab.display_name }} <div class="flex gap-3">
<span class="text-gray-800 capitalize font-semibold">
{{ collab.display_name || collab.email.slice(0, collab.email.indexOf('@')) }}
</span> </span>
</NcTooltip> </div>
<span v-else class="truncate"> <span class="text-xs text-gray-600">
{{ collab.email }} {{ collab.email }}
</span> </span>
</div> </div>
<div class="user-access-grid w-2/8"> </div>
<template v-if="accessibleRoles.includes(collab.roles)"> <div class="w-full flex-1 px-6 py-3">
<div class="w-[30px]"> <div class="w-[30px]">
<template v-if="accessibleRoles.includes(collab.roles)">
<RolesSelector <RolesSelector
:description="false"
:on-role-change="(role) => updateCollaborator(collab, role)"
:role="collab.roles" :role="collab.roles"
:roles="accessibleRoles" :roles="accessibleRoles"
:description="false"
class="cursor-pointer" class="cursor-pointer"
:on-role-change="(role) => updateCollaborator(collab, role)"
/> />
</div>
</template> </template>
<template v-else> <template v-else>
<RolesBadge :role="collab.roles" class="cursor-default" /> <RolesBadge :border="false" :role="collab.roles" class="cursor-default" />
</template> </template>
</div> </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"> <NcTooltip class="max-w-full">
<template #title> <template #title>
{{ parseStringDateTime(collab.created_at) }} {{ parseStringDateTime(collab.created_at) }}
@ -138,14 +181,35 @@ onMounted(async () => {
</span> </span>
</NcTooltip> </NcTooltip>
</div> </div>
<div class="w-1/8 pl-6"> <div class="w-full justify-end flex-1 flex px-6 py-3">
<NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']"> <NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER">
<MdiDotsVertical <NcButton size="small" type="secondary">
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)" <component :is="iconMap.threeDotVertical" />
/> </NcButton>
<template #overlay> <template #overlay>
<NcMenu> <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 /> <MaterialSymbolsDeleteOutlineRounded />
Remove user Remove user
</NcMenuItem> </NcMenuItem>
@ -154,6 +218,7 @@ onMounted(async () => {
</NcDropdown> </NcDropdown>
</div> </div>
</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 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"> <div class="text-2xl text-gray-800 font-bold">
{{ $t('placeholder.inviteYourTeam') }} {{ $t('placeholder.inviteYourTeam') }}
@ -161,8 +226,7 @@ onMounted(async () => {
<div class="text-sm text-gray-700"> <div class="text-sm text-gray-700">
{{ $t('placeholder.inviteYourTeamLabel') }} {{ $t('placeholder.inviteYourTeamLabel') }}
</div> </div>
<img src="~assets/img/placeholder/invite-team.png" class="!w-[30rem] flex-none" /> <img alt="Invite Team" class="!w-[30rem] flex-none" src="~assets/img/placeholder/invite-team.png" />
</div>
</div> </div>
</div> </div>
</div> </div>
@ -170,6 +234,18 @@ onMounted(async () => {
</template> </template>
<style scoped lang="scss"> <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 { .badge-text {
@apply text-[14px] pt-1 text-center; @apply text-[14px] pt-1 text-center;
} }

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

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

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

@ -1,5 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useTitle } from '@vueuse/core' import { useTitle } from '@vueuse/core'
import { storeToRefs } from '#imports'
const props = defineProps<{
workspaceId?: string
}>()
const router = useRouter() const router = useRouter()
const route = router.currentRoute const route = router.currentRoute
@ -7,21 +12,38 @@ const route = router.currentRoute
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { activeWorkspace, workspaces } = storeToRefs(workspaceStore) const { activeWorkspace: _activeWorkspace, workspaces } = storeToRefs(workspaceStore)
const { loadCollaborators } = 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({ const tab = computed({
get() { get() {
return route.value.query?.tab ?? 'collaborators' return route.value.query?.tab ?? 'collaborators'
}, },
set(tab: string) { set(tab: string) {
if (tab === 'collaborators') loadCollaborators() if (tab === 'collaborators') loadCollaborators({} as any, props.workspaceId)
router.push({ query: { ...route.value.query, tab } }) router.push({ query: { ...route.value.query, tab } })
}, },
}) })
watch( watch(
() => activeWorkspace.value?.title, () => currentWorkspace.value?.title,
(title: string) => { (title: string) => {
if (!title) return if (!title) return
@ -35,26 +57,40 @@ watch(
) )
onMounted(() => { onMounted(() => {
until(() => activeWorkspace.value?.id) until(() => currentWorkspace.value?.id)
.toMatch((v) => !!v) .toMatch((v) => !!v)
.then(() => { .then(async () => {
until(() => workspaces.value) await loadCollaborators({} as any, currentWorkspace.value.id)
.toMatch((v) => v.has(activeWorkspace.value.id))
.then(() => {
loadCollaborators()
})
}) })
}) })
</script> </script>
<template> <template>
<div v-if="activeWorkspace" class="flex flex-col nc-workspace-settings"> <div v-if="currentWorkspace" class="flex w-full px-6 max-w-[97.5rem] flex-col nc-workspace-settings">
<div class="flex gap-2 items-center min-w-0 p-6"> <div v-if="!props.workspaceId" class="flex gap-2 items-center min-w-0 py-6">
<GeneralWorkspaceIcon :workspace="activeWorkspace" /> <GeneralWorkspaceIcon :workspace="currentWorkspace" />
<h1 class="text-3xl font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize"> <h1 class="text-3xl capitalize font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
{{ activeWorkspace?.title }} {{ currentWorkspace?.title }}
</h1> </h1>
</div> </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"> <NcTabs v-model:activeKey="tab">
<template v-if="isUIAllowed('workspaceSettings')"> <template v-if="isUIAllowed('workspaceSettings')">
@ -65,7 +101,7 @@ onMounted(() => {
Members Members
</div> </div>
</template> </template>
<WorkspaceCollaboratorsList /> <WorkspaceCollaboratorsList :workspace-id="currentWorkspace.id" />
</a-tab-pane> </a-tab-pane>
</template> </template>
@ -77,7 +113,7 @@ onMounted(() => {
Settings Settings
</div> </div>
</template> </template>
<WorkspaceSettings /> <WorkspaceSettings :workspace-id="currentWorkspace.id" />
</a-tab-pane> </a-tab-pane>
</template> </template>
</NcTabs> </NcTabs>
@ -90,7 +126,24 @@ onMounted(() => {
font-size: 0.7rem; 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) { :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> </style>

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

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

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

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

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

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

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

@ -12,9 +12,11 @@ import {
IsPublicInj, IsPublicInj,
Modal, Modal,
NOCO, NOCO,
NcErrorType,
SharedViewPasswordInj, SharedViewPasswordInj,
computed, computed,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
extractSdkResponseErrorMsgv2,
inject, inject,
message, message,
parseProp, parseProp,
@ -188,15 +190,16 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
return row.value.row[displayValueProp.value] return row.value.row[displayValueProp.value]
}) })
const loadChildrenExcludedList = async (activeState?: any) => { const loadChildrenExcludedList = async (activeState?: any, resetOffset: boolean = false) => {
if (activeState) newRowState.state = activeState if (activeState) newRowState.state = activeState
try { try {
let offset = let offset =
childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1) - childrenExcludedOffsetCount.value
if (offset < 0) { if (offset < 0 || resetOffset) {
offset = 0 offset = 0
childrenExcludedOffsetCount.value = 0 childrenExcludedOffsetCount.value = 0
childrenExcludedListPagination.page = 1
} }
isChildrenExcludedLoading.value = true isChildrenExcludedLoading.value = true
if (isPublic.value) { if (isPublic.value) {
@ -266,7 +269,11 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
// Mark out exact same objects in activeState[column.value.title] as Linked // Mark out exact same objects in activeState[column.value.title] as Linked
// compare all keys and values // compare all keys and values
childrenExcludedList.value.list.forEach((row: any, index: number) => { 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 let isSame = true
for (const key in a) { for (const key in a) {
@ -284,27 +291,29 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
} }
} catch (e: any) { } catch (e: any) {
// temporary fix to handle when offset is beyond limit // 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 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 { } finally {
isChildrenExcludedLoading.value = false isChildrenExcludedLoading.value = false
} }
} }
const loadChildrenList = async () => { const loadChildrenList = async (resetOffset: boolean = false) => {
try { try {
isChildrenLoading.value = true isChildrenLoading.value = true
if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return if ([RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(colOptions.value.type)) return
if (!rowId.value || !column.value) return if (!rowId.value || !column.value) return
let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value let offset = childrenListPagination.size * (childrenListPagination.page - 1) + childrenListOffsetCount.value
if (offset < 0 || resetOffset) {
if (offset < 0) {
offset = 0 offset = 0
childrenListOffsetCount.value = 0 childrenListOffsetCount.value = 0
childrenListPagination.page = 1
} else if (offset >= childrenListCount.value) { } else if (offset >= childrenListCount.value) {
offset = 0 offset = 0
} }
@ -347,6 +356,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
isChildrenListLinked.value[index] = true isChildrenListLinked.value[index] = true
isChildrenListLoading.value[index] = false isChildrenListLoading.value[index] = false
}) })
if (!childrenListPagination.query) { if (!childrenListPagination.query) {
childrenListCount.value = childrenList.value?.pageInfo.totalRows ?? 0 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 isKanban = computed(() => view.value?.type === ViewTypes.KANBAN)
const isMap = computed(() => view.value?.type === ViewTypes.MAP) const isMap = computed(() => view.value?.type === ViewTypes.MAP)
const isSharedForm = computed(() => isForm.value && shared) const isSharedForm = computed(() => isForm.value && shared)
const isDefaultView = computed(() => view.value?.is_default)
const xWhere = computed(() => { const xWhere = computed(() => {
let where let where
const col = const col =
@ -100,6 +101,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
eventBus, eventBus,
sqlUi, sqlUi,
allFilters, allFilters,
isDefaultView,
} }
}, },
'smartsheet-store', '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'). * @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. * @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 clone = rfdc()
const { user } = useGlobal() const { user } = useGlobal()
@ -110,6 +110,8 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
userRoleOrder = Object.values(OrderedOrgRoles) userRoleOrder = Object.values(OrderedOrgRoles)
} else if (roleType === 'Project') { } else if (roleType === 'Project') {
userRoleOrder = Object.values(OrderedProjectRoles) userRoleOrder = Object.values(OrderedProjectRoles)
} else if (roleType === 'Organization') {
userRoleOrder = Object.values(OrderedOrgRoles)
} }
data = clone(data) data = clone(data)
@ -136,6 +138,13 @@ export function useUserSorts(roleType: 'Workspace' | 'Org' | 'Project') {
return b[sortsConfig.field]?.localeCompare(a[sortsConfig.field]) 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 return 0

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

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

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

@ -177,7 +177,7 @@ export function useViewData(
const controller = ref() 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 ((!base?.value?.id || !metaId.value || !viewMeta.value?.id) && !isPublic.value) return
if (controller.value) { if (controller.value) {
@ -188,7 +188,7 @@ export function useViewData(
controller.value = CancelToken.source() controller.value = CancelToken.source()
isPaginationLoading.value = true if (shouldShowLoading) isPaginationLoading.value = true
let response let response
try { 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 CalendarViewTypeInj: InjectionKey<Ref<'week' | 'month' | 'day' | 'year'>> = Symbol('calendar-view-type-injection')
export const JsonExpandInj: InjectionKey<Ref<boolean>> = Symbol('json-expand-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 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": { "general": {
"role": "Role",
"general": "General",
"quit": "Quit", "quit": "Quit",
"home": "الرئيسية", "home": "الرئيسية",
"load": "تحميل", "load": "تحميل",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "مشاهد", "viewer": "مشاهد",
"noaccess": "No Access", "noaccess": "No Access",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator", "orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer" "orgLevelViewer": "Organization Level Viewer"
}, },
@ -313,6 +319,10 @@
"isNotNull": "ليس فارغاً" "isNotNull": "ليس فارغاً"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "Docs", "docs": "Docs",
"forum": "Forum", "forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell", "pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "البحث عن {بحث} لم يتم العثور على نتائج", "searchProject": "البحث عن {بحث} لم يتم العثور على نتائج",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Quit", "quit": "Quit",
"home": "বি", "home": "বি",
"load": "ভর", "load": "ভর",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "দরশক", "viewer": "দরশক",
"noaccess": "No Access", "noaccess": "No Access",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator", "orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer" "orgLevelViewer": "Organization Level Viewer"
}, },
@ -313,6 +319,10 @@
"isNotNull": "নল নয" "isNotNull": "নল নয"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "Docs", "docs": "Docs",
"forum": "Forum", "forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell", "pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "আপনর অনসনন {search} এর জনয কনও ফলফল পওযি", "searchProject": "আপনর অনসনন {search} এর জনয কনও ফলফল পওযি",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Quit", "quit": "Quit",
"home": "Domů", "home": "Domů",
"load": "Načíst", "load": "Načíst",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "Sledující", "viewer": "Sledující",
"noaccess": "No Access", "noaccess": "No Access",
"superAdmin": "Hlavní administrátor", "superAdmin": "Hlavní administrátor",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Tvůrce na úrovni organizace", "orgLevelCreator": "Tvůrce na úrovni organizace",
"orgLevelViewer": "Prohlížeč na úrovni organizace" "orgLevelViewer": "Prohlížeč na úrovni organizace"
}, },
@ -313,6 +319,10 @@
"isNotNull": "není null" "isNotNull": "není null"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "Dokumentace", "docs": "Dokumentace",
"forum": "Fórum", "forum": "Fórum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Operace Vložit není v aktivní buňce podporována.", "pasteNotSupported": "Operace Vložit není v aktivní buňce podporována.",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Vaše hledání na {search} nenašlo žádné výsledky", "searchProject": "Vaše hledání na {search} nenašlo žádné výsledky",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Quit", "quit": "Quit",
"home": "Forside", "home": "Forside",
"load": "Indlæs", "load": "Indlæs",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "Viewer.", "viewer": "Viewer.",
"noaccess": "No Access", "noaccess": "No Access",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Skaberen på organisationsniveau", "orgLevelCreator": "Skaberen på organisationsniveau",
"orgLevelViewer": "Visning på organisationsniveau" "orgLevelViewer": "Visning på organisationsniveau"
}, },
@ -313,6 +319,10 @@
"isNotNull": "er ikke null." "isNotNull": "er ikke null."
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "Docs", "docs": "Docs",
"forum": "Forum", "forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Indsæt er ikke understøttet på den aktive celle", "pasteNotSupported": "Indsæt er ikke understøttet på den aktive celle",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Din søgning efter {Søg} viste ingen resultater", "searchProject": "Din søgning efter {Søg} viste ingen resultater",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Beenden", "quit": "Beenden",
"home": "Start", "home": "Start",
"load": "Laden", "load": "Laden",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown-Liste", "dropdown": "Dropdown-Liste",
"list": "Liste", "list": "Liste",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Tag", "day": "Tag",
"week": "Woche", "week": "Woche",
"month": "Monat", "month": "Monat",
@ -247,6 +252,7 @@
"viewer": "Betrachter", "viewer": "Betrachter",
"noaccess": "Kein Zugriff", "noaccess": "Kein Zugriff",
"superAdmin": "Super-Admin", "superAdmin": "Super-Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organisationsebenen-Ersteller", "orgLevelCreator": "Organisationsebenen-Ersteller",
"orgLevelViewer": "Organisationsebenen-Betrachter" "orgLevelViewer": "Organisationsebenen-Betrachter"
}, },
@ -313,6 +319,10 @@
"isNotNull": "ist nicht Null" "isNotNull": "ist nicht Null"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentifizierung (SSO)", "sso": "Authentifizierung (SSO)",
"docs": "Dokumentation", "docs": "Dokumentation",
"forum": "Forum", "forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Vorherige", "previous": "Vorherige",
"nextMonth": "Folgender Monat", "nextMonth": "Folgender Monat",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Passwort eingeben", "enterPassword": "Passwort eingeben",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Der Vorgang Einfügen wird auf der aktiven Zelle nicht unterstützt", "pasteNotSupported": "Der Vorgang Einfügen wird auf der aktiven Zelle nicht unterstützt",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Ihre Suche nach {search} fand keine Ergebnisse", "searchProject": "Ihre Suche nach {search} fand keine Ergebnisse",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Quit", "quit": "Quit",
"home": "Home", "home": "Home",
"load": "Load", "load": "Load",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "Viewer", "viewer": "Viewer",
"noaccess": "No Access", "noaccess": "No Access",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator", "orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer" "orgLevelViewer": "Organization Level Viewer"
}, },
@ -313,6 +319,10 @@
"isNotNull": "is not null" "isNotNull": "is not null"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "Docs", "docs": "Docs",
"forum": "Forum", "forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results" "noResultsMatchedYourSearch": "Your search did not yield any matching results"
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "SAML", "saml": "SAML",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell", "pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Your search for {search} found no results", "searchProject": "Your search for {search} found no results",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Salir", "quit": "Salir",
"home": "Inicio", "home": "Inicio",
"load": "Cargar", "load": "Cargar",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Día", "day": "Día",
"week": "Semana", "week": "Semana",
"month": "Mes", "month": "Mes",
@ -247,6 +252,7 @@
"viewer": "Visor", "viewer": "Visor",
"noaccess": "Sin acceso", "noaccess": "Sin acceso",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Creador a nivel de organización", "orgLevelCreator": "Creador a nivel de organización",
"orgLevelViewer": "Visor de nivel de organización" "orgLevelViewer": "Visor de nivel de organización"
}, },
@ -313,6 +319,10 @@
"isNotNull": "no es nulo" "isNotNull": "no es nulo"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Autenticación (SSO)", "sso": "Autenticación (SSO)",
"docs": "Documentos", "docs": "Documentos",
"forum": "Foro", "forum": "Foro",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Seleccionar Año",
"save": "Guardar", "save": "Guardar",
"cancel": "Cancelar", "cancel": "Cancelar",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "Nuevo proveedor", "newProvider": "Nuevo proveedor",
"generalSettings": "Ajustes Generales", "generalSettings": "Ajustes Generales",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "Ajustes SSO", "ssoSettings": "Ajustes SSO",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organizar por", "organizeBy": "Organizar por",
"previous": "Anterior", "previous": "Anterior",
"nextMonth": "Mes siguiente", "nextMonth": "Mes siguiente",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "La vista del calendario requiere un rango de fechas", "noRange": "La vista del calendario requiere un rango de fechas",
"goToToday": "Ir a Hoy", "goToToday": "Ir a Hoy",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Introducir la contraseña", "enterPassword": "Introducir la contraseña",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "Un único registro de la tabla ", "tooltip_desc": "Un único registro de la tabla ",
"tooltip_desc2": " puede vincularse con 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", "noRecordsLinked": "No hay registros vinculados",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "registros vinculados", "recordsLinked": "registros vinculados",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "No se admite la operación de pegado en la celda activa", "pasteNotSupported": "No se admite la operación de pegado en la celda activa",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Tu búsqueda de {search} no encontró resultados", "searchProject": "Tu búsqueda de {search} no encontró resultados",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Irten", "quit": "Irten",
"home": "Hasiera", "home": "Hasiera",
"load": "Kargatu", "load": "Kargatu",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "Ikuslea", "viewer": "Ikuslea",
"noaccess": "No Access", "noaccess": "No Access",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organization Level Creator", "orgLevelCreator": "Organization Level Creator",
"orgLevelViewer": "Organization Level Viewer" "orgLevelViewer": "Organization Level Viewer"
}, },
@ -313,6 +319,10 @@
"isNotNull": "is not null" "isNotNull": "is not null"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "Dokumentuak", "docs": "Dokumentuak",
"forum": "Forum", "forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Paste operation is not supported on the active cell", "pasteNotSupported": "Paste operation is not supported on the active cell",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Your search for {search} found no results", "searchProject": "Your search for {search} found no results",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "خروج", "quit": "خروج",
"home": "خانه", "home": "خانه",
"load": "لود کردن", "load": "لود کردن",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "بیننده", "viewer": "بیننده",
"noaccess": "بدون دسترسی", "noaccess": "بدون دسترسی",
"superAdmin": "مدیر ارشد", "superAdmin": "مدیر ارشد",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "تولید کننده سطح سازمانی", "orgLevelCreator": "تولید کننده سطح سازمانی",
"orgLevelViewer": "مشاهده کننده سطح سازمانی" "orgLevelViewer": "مشاهده کننده سطح سازمانی"
}, },
@ -313,6 +319,10 @@
"isNotNull": "تهی نیست" "isNotNull": "تهی نیست"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "مستندات", "docs": "مستندات",
"forum": "انجمن", "forum": "انجمن",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "عملیات جایگذاری در سلول فعال پشتیبانی نمیشود", "pasteNotSupported": "عملیات جایگذاری در سلول فعال پشتیبانی نمیشود",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "جستوجوی شما برای {search} نتیجهای نداشت", "searchProject": "جستوجوی شما برای {search} نتیجهای نداشت",

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

@ -39,6 +39,8 @@
} }
}, },
"general": { "general": {
"role": "Role",
"general": "General",
"quit": "Quit", "quit": "Quit",
"home": "Koti", "home": "Koti",
"load": "Ladata", "load": "Ladata",
@ -198,11 +200,14 @@
"logo": "Logo", "logo": "Logo",
"dropdown": "Dropdown", "dropdown": "Dropdown",
"list": "List", "list": "List",
"verify": "Verify",
"apply": "Apply", "apply": "Apply",
"text": "Text", "text": "Text",
"appearance": "Appearance" "appearance": "Appearance"
}, },
"objects": { "objects": {
"owner": "Owner",
"member": "Member",
"day": "Day", "day": "Day",
"week": "Week", "week": "Week",
"month": "Month", "month": "Month",
@ -247,6 +252,7 @@
"viewer": "Katselija", "viewer": "Katselija",
"noaccess": "No Access", "noaccess": "No Access",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"orgLevelOwner": "Organization Level Owner",
"orgLevelCreator": "Organisaatiotason luoja", "orgLevelCreator": "Organisaatiotason luoja",
"orgLevelViewer": "Organisaatiotason katseluohjelma" "orgLevelViewer": "Organisaatiotason katseluohjelma"
}, },
@ -313,6 +319,10 @@
"isNotNull": "ei ole nolla" "isNotNull": "ei ole nolla"
}, },
"title": { "title": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename Workspace",
"renamingWorkspace": "Renaming Workspace",
"renamingBase": "Renaming Base",
"sso": "Authentication (SSO)", "sso": "Authentication (SSO)",
"docs": "Docs", "docs": "Docs",
"forum": "Forum", "forum": "Forum",
@ -437,6 +447,39 @@
"noResultsMatchedYourSearch": "Your search did not yield any matching results." "noResultsMatchedYourSearch": "Your search did not yield any matching results."
}, },
"labels": { "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", "selectYear": "Select Year",
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",
@ -447,7 +490,15 @@
"saml": "Security Assertion Markup Language (SAML)", "saml": "Security Assertion Markup Language (SAML)",
"newProvider": "New Provider", "newProvider": "New Provider",
"generalSettings": "General Settings", "generalSettings": "General Settings",
"adminPanel": "Admin Panel",
"moveWorkspaceToOrg": "Move Workspace To Organisation",
"ssoSettings": "SSO Settings", "ssoSettings": "SSO Settings",
"addDomain": "Add Domain",
"domain": "Domain",
"settings": "Settings",
"workspaces": "Workspaces",
"back": "Back",
"dashboard": "Dashboard",
"organizeBy": "Organize by", "organizeBy": "Organize by",
"previous": "Previous", "previous": "Previous",
"nextMonth": "Next Month", "nextMonth": "Next Month",
@ -709,9 +760,16 @@
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
"activity": { "activity": {
"renameBase": "Rename Base",
"renameWorkspace": "Rename workspace",
"deactivate": "De-activate",
"manageUsers": "Manage Users",
"newWorkspace": "New Workspace",
"addDomain": "Add Domain",
"addMembers": "Add Members", "addMembers": "Add Members",
"enterEmail": "Enter email addresses", "enterEmail": "Enter email addresses",
"inviteToBase": "Invite to Base", "inviteToBase": "Invite to Base",
"inviteToWorkspace": "Invite to Workspace",
"addMember": "Add Member to Base", "addMember": "Add Member to Base",
"noRange": "Calendar view requires a date range", "noRange": "Calendar view requires a date range",
"goToToday": "Go to Today", "goToToday": "Go to Today",
@ -1036,6 +1094,11 @@
"searchOptions": "Search options" "searchOptions": "Search options"
}, },
"msg": { "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", "clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
@ -1118,7 +1181,7 @@
"tooltip_desc": "A single record from table ", "tooltip_desc": "A single record from table ",
"tooltip_desc2": " can be linked with 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", "noRecordsLinked": "No records linked",
"noLinkedRecords": "No linked records", "noLinkedRecords": "No linked records",
"recordsLinked": "records linked", "recordsLinked": "records linked",
@ -1162,8 +1225,11 @@
} }
}, },
"info": { "info": {
"enterWorkspaceName": "Enter workspace name",
"enterBaseName": "Enter base name",
"idpPaste": "Paste these URL in your Identity Providers console", "idpPaste": "Paste these URL in your Identity Providers console",
"noSaml": "There are no configured SAML authentications.", "noSaml": "There are no configured SAML authentications.",
"noOIDC": "There are no configured OpenID authentications.",
"disabledAsViewLocked": "Disabled as View is locked", "disabledAsViewLocked": "Disabled as View is locked",
"basesMigrated": "Bases are migrated. Please try again.", "basesMigrated": "Bases are migrated. Please try again.",
"pasteNotSupported": "Liitä-toimintoa ei tueta aktiivisessa solussa.", "pasteNotSupported": "Liitä-toimintoa ei tueta aktiivisessa solussa.",
@ -1339,6 +1405,7 @@
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",
"fetchingActiveDates": "Error fetching active dates", "fetchingActiveDates": "Error fetching active dates",
"scopesRequired": "Scopes required", "scopesRequired": "Scopes required",
"domainRequired": "Domain name is required",
"authUrlRequired": "Auth URL is required", "authUrlRequired": "Auth URL is required",
"userNameAttributeRequired": "Username attribute is required", "userNameAttributeRequired": "Username attribute is required",
"clientIdRequired": "Client ID is required", "clientIdRequired": "Client ID is required",
@ -1352,6 +1419,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"domainNameRequired": "Domain name is required",
"nameMaxLength256": "Name must be at most 256 characters long", "nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Hakusi {haku} ei löytänyt tuloksia", "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