Browse Source

Merge pull request #7001 from nocodb/develop

pull/7002/head 0.202.6
github-actions[bot] 10 months ago committed by GitHub
parent
commit
2586791f8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .github/workflows/ci-cd.yml
  2. 18
      .github/workflows/playwright-test-workflow.yml
  3. 26
      .github/workflows/pre-build-for-playwright.yml
  4. 1
      .github/workflows/release-executables.yml
  5. 2
      .github/workflows/unit-test.yml
  6. 4
      .github/workflows/update-sdk-path.yml
  7. 6
      README.md
  8. 2
      packages/nc-gui/assets/nc-icons/check.svg
  9. 6
      packages/nc-gui/assets/nc-icons/file-image.svg
  10. 4
      packages/nc-gui/assets/nc-icons/lock.svg
  11. 6
      packages/nc-gui/assets/nc-icons/sort.svg
  12. 85
      packages/nc-gui/assets/style.scss
  13. 2
      packages/nc-gui/components/account/License.vue
  14. 2
      packages/nc-gui/components/account/Profile.vue
  15. 16
      packages/nc-gui/components/account/UserList.vue
  16. 7
      packages/nc-gui/components/account/UsersModal.vue
  17. 2
      packages/nc-gui/components/cell/Currency.vue
  18. 11
      packages/nc-gui/components/cell/DatePicker.vue
  19. 11
      packages/nc-gui/components/cell/DateTimePicker.vue
  20. 2
      packages/nc-gui/components/cell/Decimal.vue
  21. 2
      packages/nc-gui/components/cell/Duration.vue
  22. 3
      packages/nc-gui/components/cell/Email.vue
  23. 2
      packages/nc-gui/components/cell/Float.vue
  24. 4
      packages/nc-gui/components/cell/GeoData.vue
  25. 8
      packages/nc-gui/components/cell/Json.vue
  26. 8
      packages/nc-gui/components/cell/MultiSelect.vue
  27. 2
      packages/nc-gui/components/cell/Percent.vue
  28. 10
      packages/nc-gui/components/cell/PhoneNumber.vue
  29. 4
      packages/nc-gui/components/cell/SingleSelect.vue
  30. 2
      packages/nc-gui/components/cell/Text.vue
  31. 24
      packages/nc-gui/components/cell/TextArea.vue
  32. 8
      packages/nc-gui/components/cell/TimePicker.vue
  33. 2
      packages/nc-gui/components/cell/Url.vue
  34. 8
      packages/nc-gui/components/cell/YearPicker.vue
  35. 6
      packages/nc-gui/components/cell/attachment/RenameFile.vue
  36. 1
      packages/nc-gui/components/cell/attachment/utils.ts
  37. 2
      packages/nc-gui/components/dashboard/Sidebar/TopSection.vue
  38. 66
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  39. 1
      packages/nc-gui/components/dashboard/TreeView/AddNewTableNode.vue
  40. 32
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  41. 4
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  42. 3
      packages/nc-gui/components/dashboard/TreeView/TableList.vue
  43. 20
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  44. 79
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  45. 77
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  46. 3
      packages/nc-gui/components/dashboard/TreeView/index.vue
  47. 63
      packages/nc-gui/components/dashboard/View.vue
  48. 11
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  49. 3
      packages/nc-gui/components/dashboard/settings/Erd.vue
  50. 51
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  51. 11
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  52. 8
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  53. 24
      packages/nc-gui/components/dlg/AirtableImport.vue
  54. 136
      packages/nc-gui/components/dlg/ColumnDuplicate.vue
  55. 16
      packages/nc-gui/components/dlg/KeyboardShortcuts.vue
  56. 2
      packages/nc-gui/components/dlg/ProjectErd.vue
  57. 16
      packages/nc-gui/components/dlg/QuickImport.vue
  58. 8
      packages/nc-gui/components/dlg/ViewCreate.vue
  59. 4
      packages/nc-gui/components/dlg/share-and-collaborate/Collaborate.vue
  60. 5
      packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue
  61. 4
      packages/nc-gui/components/dlg/share-and-collaborate/View.vue
  62. 5
      packages/nc-gui/components/erd/View.vue
  63. 37
      packages/nc-gui/components/general/CopyUrl.vue
  64. 15
      packages/nc-gui/components/general/DeleteModal.vue
  65. 3
      packages/nc-gui/components/general/FullScreen.vue
  66. 4
      packages/nc-gui/components/general/JoinCloud.vue
  67. 2
      packages/nc-gui/components/general/Share.vue
  68. 2
      packages/nc-gui/components/general/ShareProject.vue
  69. 2
      packages/nc-gui/components/general/Social.vue
  70. 18
      packages/nc-gui/components/general/UserIcon.vue
  71. 1
      packages/nc-gui/components/general/language/Menu.vue
  72. 10
      packages/nc-gui/components/nc/Dropdown.vue
  73. 121
      packages/nc-gui/components/nc/Pagination.vue
  74. 7
      packages/nc-gui/components/notification/Card.vue
  75. 2
      packages/nc-gui/components/project/AccessSettings.vue
  76. 8
      packages/nc-gui/components/project/View.vue
  77. 4
      packages/nc-gui/components/roles/Badge.vue
  78. 4
      packages/nc-gui/components/shared-view/Gallery.vue
  79. 6
      packages/nc-gui/components/shared-view/Grid.vue
  80. 4
      packages/nc-gui/components/shared-view/Kanban.vue
  81. 4
      packages/nc-gui/components/shared-view/Map.vue
  82. 1
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  83. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  84. 8
      packages/nc-gui/components/smartsheet/Form.vue
  85. 8
      packages/nc-gui/components/smartsheet/Pagination.vue
  86. 11
      packages/nc-gui/components/smartsheet/Row.vue
  87. 7
      packages/nc-gui/components/smartsheet/Toolbar.vue
  88. 6
      packages/nc-gui/components/smartsheet/column/DecimalOptions.vue
  89. 13
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  90. 4
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  91. 7
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  92. 34
      packages/nc-gui/components/smartsheet/column/SelectOptions.vue
  93. 2
      packages/nc-gui/components/smartsheet/details/Erd.vue
  94. 298
      packages/nc-gui/components/smartsheet/details/Fields.vue
  95. 17
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  96. 94
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  97. 39
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  98. 28
      packages/nc-gui/components/smartsheet/grid/GroupByLabel.vue
  99. 161
      packages/nc-gui/components/smartsheet/grid/Table.vue
  100. 150
      packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts
  101. Some files were not shown because too many files have changed in this diff Show More

4
.github/workflows/ci-cd.yml

@ -45,6 +45,8 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: remove use-node-version from .npmrc
run: sed -i '/^use-node-version/d' .npmrc
- name: Get pnpm store directory
shell: bash
run: |
@ -78,6 +80,8 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: remove use-node-version from .npmrc
run: sed -i '/^use-node-version/d' .npmrc
- name: Get pnpm store directory
shell: bash
run: |

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

@ -18,31 +18,21 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Check node,pnpm Installation and set Path
shell: bash
working-directory: scripts/self-hosted-gh-runner
timeout-minutes: 1
run: |
./node-pnpm-check.sh
echo "make sure below mentioned versions are expected versions"
echo "If you are expecting the node and pnpm versions to be updated. Please update the node-pnpm-check.sh script"
env
- name: remove use-node-version from .npmrc
run: sed -i '/^use-node-version/d' .npmrc
- name: Setup Node
if: ${{ env.SETUP_NODE != 'false' }}
uses: actions/setup-node@v3
with:
node-version: ${{ env.NC_REQ_NODE_V }}
node-version: 18.14.0
- name: Setup pnpm
if: ${{ env.SETUP_PNPM != 'false' }}
uses: pnpm/action-setup@v2
with:
version: ${{ env.NC_REQ_PNPM_V }}
version: 8
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=/root/setup-pnpm/node_modules/.bin/store/v3" >> $GITHUB_ENV
- uses: actions/cache@v3
if: env.IS_NPM_CACHE_DOWNLOAD_REQUIRED == 'true'
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}

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

@ -2,13 +2,7 @@ name: pre-build-for-playwright
on:
workflow_call:
inputs:
FORCE_RUN_PRERQUISITE_STEPS:
description: 'FORCE_RUN_PRERQUISITE_STEPS'
required: false
type: string
default: 'false'
jobs:
playwright:
runs-on: [self-hosted, v3]
@ -16,31 +10,21 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Check node,pnpm Installation and set Path
shell: bash
working-directory: scripts/self-hosted-gh-runner
timeout-minutes: 1
run: |
./node-pnpm-check.sh
echo "make sure below mentioned versions are expected versions"
echo "If you are expecting the node and pnpm versions to be updated. Please update the node-pnpm-check.sh script"
env
- name: Setup Node
if: ${{ env.SETUP_NODE != 'false' }}
uses: actions/setup-node@v3
with:
node-version: ${{ env.NC_REQ_NODE_V }}
node-version: 18.14.0
- name: Setup pnpm
if: ${{ env.SETUP_PNPM != 'false' }}
uses: pnpm/action-setup@v2
with:
version: ${{ env.NC_REQ_PNPM_V }}
version: 8
- name: remove use-node-version from .npmrc
run: sed -i '/^use-node-version/d' .npmrc
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=/root/setup-pnpm/node_modules/.bin/store/v3" >> $GITHUB_ENV
- uses: actions/cache@v3
if: env.IS_NPM_CACHE_DOWNLOAD_REQUIRED == 'true'
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}

1
.github/workflows/release-executables.yml

@ -25,6 +25,7 @@ jobs:
- name: Get pnpm store directory
shell: bash
run: |
sed -i '/^use-node-version/d' .npmrc
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache

2
.github/workflows/unit-test.yml

@ -34,6 +34,8 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: remove use-node-version from .npmrc
run: sed -i '/^use-node-version/d' .npmrc
- name: install dependencies
run: pnpm bootstrap
- name: run unit tests

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

@ -20,9 +20,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
fetch-depth: 0
- run: |
sed -i '/^use-node-version/d' .npmrc
pnpm bootstrap
- name: Create Pull Request

6
README.md

@ -29,7 +29,7 @@ Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart spreadshe
<a href="https://docs.nocodb.com/"><b>Documentation</b></a>
</p>
![All Views](https://user-images.githubusercontent.com/35857179/194825053-3aa3373d-3e0f-4b42-b3f1-42928332054a.gif)
![video avi](https://github.com/nocodb/nocodb/assets/86527202/e2fad786-f211-4dcb-9bd3-aaece83a6783)
<div align="center">
@ -106,6 +106,8 @@ nocodb/nocodb:latest
> If you plan to input some special characters, you may need to change the character set and collation yourself when creating the database. Please check out the examples for [MySQL Docker](https://github.com/nocodb/nocodb/issues/1340#issuecomment-1049481043).
> Different commands just indicate the database that NocoDB will use internally for metadata storage, but that doesn't influence the ability to connect to a different database type.
## Binaries
##### MacOS (x64)
@ -305,4 +307,4 @@ Thank you for your contributions! We appreciate all the contributions from the c
<a href="https://github.com/nocodb/nocodb/graphs/contributors">
<img src="https://contrib.rocks/image?repo=nocodb/nocodb" />
</a>
</a>

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

@ -1,5 +1,5 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="check">
<path id="Vector" d="M13.3333 4.5L5.99996 11.8333L2.66663 8.5" stroke="#40444D" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector" d="M13.3333 4.5L5.99996 11.8333L2.66663 8.5" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 280 B

6
packages/nc-gui/assets/nc-icons/file-image.svg

@ -0,0 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="white" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" fill="#F4F4F5"/>
<path d="M28.6667 18H19.3333C18.597 18 18 18.597 18 19.3333V28.6667C18 29.403 18.597 30 19.3333 30H28.6667C29.403 30 30 29.403 30 28.6667V19.3333C30 18.597 29.403 18 28.6667 18Z" stroke="#9AA2AF" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M30.0002 25.9998L26.6668 22.6665L19.3335 29.9998" stroke="#9AA2AF" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21.6665 22.6665C22.2188 22.6665 22.6665 22.2188 22.6665 21.6665C22.6665 21.1142 22.2188 20.6665 21.6665 20.6665C21.1142 20.6665 20.6665 21.1142 20.6665 21.6665C20.6665 22.2188 21.1142 22.6665 21.6665 22.6665Z" stroke="#9AA2AF" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 805 B

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

@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6667 7.33337H3.33333C2.59695 7.33337 2 7.93033 2 8.66671V13.3334C2 14.0698 2.59695 14.6667 3.33333 14.6667H12.6667C13.403 14.6667 14 14.0698 14 13.3334V8.66671C14 7.93033 13.403 7.33337 12.6667 7.33337Z" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66667 7.33337V4.66671C4.66667 3.78265 5.01786 2.93481 5.64298 2.30968C6.2681 1.68456 7.11595 1.33337 8 1.33337C8.88406 1.33337 9.73191 1.68456 10.357 2.30968C10.9821 2.93481 11.3333 3.78265 11.3333 4.66671V7.33337" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.6667 7.33337H3.33333C2.59695 7.33337 2 7.93033 2 8.66671V13.3334C2 14.0698 2.59695 14.6667 3.33333 14.6667H12.6667C13.403 14.6667 14 14.0698 14 13.3334V8.66671C14 7.93033 13.403 7.33337 12.6667 7.33337Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66667 7.33337V4.66671C4.66667 3.78265 5.01786 2.93481 5.64298 2.30968C6.2681 1.68456 7.11595 1.33337 8 1.33337C8.88406 1.33337 9.73191 1.68456 10.357 2.30968C10.9821 2.93481 11.3333 3.78265 11.3333 4.66671V7.33337" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 725 B

After

Width:  |  Height:  |  Size: 735 B

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

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 13.3334V10.6667" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.3334V6.66669" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 13.3334V2.66669" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 13.3334V10.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.3334V6.66669" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 13.3334V2.66669" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 458 B

After

Width:  |  Height:  |  Size: 473 B

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

@ -2,6 +2,10 @@
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
html {
overflow: hidden;
}
body {
line-height: 1.3125rem;
}
@ -54,7 +58,7 @@ main {
}
.mobile {
.nc-scrollbar-md, nc-scrollbar-dark-md, nc-scrollbar-dark-md, nc-scrollbar-sm-dark, nc-scrollbar-x-md {
.nc-scrollbar-md, .nc-scrollbar-x-md, .nc-scrollbar-dark-md, .nc-scrollbar-x-md-dark, .nc-scrollbar-x-lg {
&::-webkit-scrollbar {
width: 0px;
}
@ -64,6 +68,7 @@ main {
.nc-scrollbar-md {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 4px;
@ -77,16 +82,46 @@ main {
}
&::-webkit-scrollbar-thumb {
width: 4px;
@apply bg-gray-200;
}
&::-webkit-scrollbar-thumb:hover {
@apply bg-gray-300;
}
}
.nc-scrollbar-x-md {
overflow-x: scroll;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track-piece {
width: 0px;
}
&::-webkit-scrollbar {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
-webkit-border-radius: 10px;
border-radius: 10px;
width: 4px;
@apply bg-gray-200;
}
&::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400;
@apply bg-gray-300;
}
}
.nc-scrollbar-dark-md {
overflow-y: scroll;
overflow-x: hidden;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 4px;
@ -115,13 +150,14 @@ main {
}
}
.nc-scrollbar-sm-dark {
overflow-y: scroll;
overflow-x: hidden;
.nc-scrollbar-x-md-dark {
overflow-x: scroll;
scrollbar-width: thin !important;
&::-webkit-scrollbar {
width: 2px;
height: 2px;
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track-piece {
width: 0px;
@ -130,20 +166,26 @@ main {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
-webkit-border-radius: 10px;
border-radius: 10px;
width: 4px;
@apply bg-gray-300;
background-color: rgba(0, 0, 0, 0.3)
}
&::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400;
background-color: rgba(0, 0, 0, 0.4)
}
}
.nc-scrollbar-x-md {
.nc-scrollbar-x-lg {
overflow-x: scroll;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track-piece {
width: 0px;
@ -152,7 +194,11 @@ main {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
width: 4px;
-webkit-border-radius: 10px;
border-radius: 10px;
width: 8px;
@apply bg-gray-200;
}
&::-webkit-scrollbar-thumb:hover {
@ -160,10 +206,6 @@ main {
}
}
html {
overflow-y: auto !important;
}
main {
@apply flex-0 w-full relative scrollbar-thin-dull;
overflow-x: hidden;
@ -194,7 +236,7 @@ a {
}
.nc-base-menu-item {
@apply cursor-pointer flex items-center gap-2 py-2 after:(content-[''] absolute top-0 left-0 bottom-0 right-0 w-full h-full bg-current opacity-0 transition transition-opacity duration-100) hover:(after:(opacity-5));
@apply cursor-pointer flex items-center gap-2 py-2;
// &:hover {
// .nc-icon {
@ -437,6 +479,9 @@ a {
.nc-toolbar-btn {
@apply !shadow-none rounded hover:(ring-1 ring-gray-200 ring-opacity-100 bg-gray-100 !text-gray-800) focus:(ring-1 ring-gray-300 ring-opacity-100 !text-gray-800 bg-gray-100) text-gray-600 text-xs font-medium px-2 border-0;
}
.nc-toolbar-btn[disabled] {
@apply !text-gray-400 !cursor-not-allowed !hover:ring-0;
}
.nc-warning-info {
@apply !shadow-none rounded ring-1 ring-red-600;
@ -633,3 +678,7 @@ input[type='number'] {
@apply xs:(visible opacity-100 !text-gray-500)
}
}
.ant-message-notice-content {
@apply !rounded-md;
}

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { useNuxtApp } from '#app'
import { useNuxtApp } from '#imports'
import { message } from 'ant-design-vue'
import { extractSdkResponseErrorMsg, useApi, useGlobal } from '#imports'

2
packages/nc-gui/components/account/Profile.vue

@ -69,7 +69,7 @@ const onValidate = async (_: any, valid: boolean) => {
<div class="flex text-gray-500" data-rec="true">{{ $t('labels.controlAppearance') }}</div>
<div class="flex flex-row mt-4">
<div class="flex h-20 mt-1.5">
<GeneralUserIcon size="xlarge" :email="user?.email" />
<GeneralUserIcon size="xlarge" :email="user?.email" :name="user?.display_name" />
</div>
<div class="flex w-10"></div>
<a-form

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

@ -15,6 +15,8 @@ const { t } = useI18n()
const { dashboardUrl } = useDashboard()
const { user: loggedInUser } = useGlobal()
const { copy } = useCopy()
const users = ref<UserType[]>([])
@ -155,7 +157,7 @@ const openDeleteModal = (user: UserType) => {
<template>
<div data-testid="nc-super-user-list" class="h-full">
<div class="max-w-195 mx-auto h-full">
<div class="text-2xl text-left font-weight-bold mb-4" data-rec="true">{{ $t('title.userManagement') }}</div>
<div class="text-2xl text-left font-weight-bold mb-4" data-rec="true">{{ $t('title.userMgmt') }}</div>
<div class="py-2 flex gap-4 items-center justify-between">
<a-input v-model:value="searchText" class="!max-w-90 !rounded-md" placeholder="Search members" @change="loadUsers()">
<template #prefix>
@ -269,11 +271,13 @@ const openDeleteModal = (user: UserType) => {
<div>{{ $t('activity.copyPasswordResetURL') }}</div>
</NcMenuItem>
</template>
<NcDivider v-if="!el.roles?.includes('super')" />
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="openDeleteModal(el)">
<MaterialSymbolsDeleteOutlineRounded />
{{ $t('general.remove') }} {{ $t('objects.user') }}
</NcMenuItem>
<template v-if="el.id !== loggedInUser?.id">
<NcDivider v-if="!el.roles?.includes('super')" />
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="openDeleteModal(el)">
<MaterialSymbolsDeleteOutlineRounded />
{{ $t('general.remove') }} {{ $t('objects.user') }}
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>

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

@ -175,7 +175,7 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
v-bind="validateInfos.emails"
validate-trigger="onBlur"
name="emails"
:rules="[{ required: true, message: 'Please input email' }]"
:rules="[{ required: true, message: $t('msg.plsInputEmail') }]"
>
<div class="ml-1 mb-1 text-xs text-gray-500" data-rec="true">{{ $t('datatype.Email') }}:</div>
@ -190,9 +190,8 @@ const emailInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</div>
<div class="flex flex-col w-2/4">
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]">
<div class="ml-1 mb-1 text-xs text-gray-500" data-rec="true">{{ $t('labels.selectUserRole') }}</div>
<a-form-item name="role" :rules="[{ required: true, message: $t('msg.roleRequired') }]">
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('labels.selectUserRole') }}</div>
<a-select v-model:value="usersData.role" class="nc-user-roles" dropdown-class-name="nc-dropdown-user-role">
<a-select-option
class="nc-role-option"

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

@ -86,8 +86,6 @@ onMounted(() => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
@contextmenu.stop

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

@ -22,7 +22,6 @@ interface Props {
}
const { modelValue, isPk } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
@ -186,6 +185,14 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
}
})
const isOpen = computed(() => {
if (readOnly.value) return false
return ((readOnly.value || (localState.value && isPk)) && !active.value && !editable.value) || isLockedMode.value
? false
: open.value
})
// use the default date picker open sync only to close the picker
const updateOpen = (next: boolean) => {
if (open.value && !next) {
@ -223,7 +230,7 @@ const clickHandler = () => {
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:dropdown-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''}`"
:open="((readOnly || (localState && isPk)) && !active && !editable) || isLockedMode ? false : open"
:open="isOpen"
@click="clickHandler"
@update:open="updateOpen"
>

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

@ -25,7 +25,6 @@ interface Props {
}
const { modelValue, isPk, isUpdatedFromCopyNPaste } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMssql, isXcdbBase } = useBase()
@ -124,6 +123,14 @@ const localState = computed({
const open = ref(false)
const isOpen = computed(() => {
if (readOnly.value) return false
return readOnly.value || (localState.value && isPk) || isLockedMode.value
? false
: open.value && (active.value || editable.value)
})
const randomClass = `picker_${Math.floor(Math.random() * 99999)}`
watch(
open,
@ -271,7 +278,7 @@ const isColDisabled = computed(() => {
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:dropdown-class-name="`${randomClass} nc-picker-datetime ${open ? 'active' : ''}`"
:open="readOnly || (localState && isPk) || isLockedMode ? false : open && (active || editable)"
:open="isOpen"
@click="clickHandler"
@ok="open = !open"
>

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

@ -105,8 +105,6 @@ watch(isExpandedFormOpen, () => {
@keydown.right.stop
@keydown.up.stop="onKeyDown"
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>

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

@ -103,8 +103,6 @@ const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>

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

@ -78,8 +78,6 @@ watch(
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>
@ -88,6 +86,7 @@ watch(
<nuxt-link
v-else-if="validEmail"
no-ref
class="text-sm underline hover:opacity-75 inline-block"
:href="`mailto:${vModel}`"
target="_blank"

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

@ -58,8 +58,6 @@ const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>

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

@ -72,13 +72,13 @@ const onClickSetCurrentLocation = () => {
const openInGoogleMaps = () => {
const [latitude, longitude] = (vModel.value || '').split(';')
const url = `https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`
window.open(url, '_blank')
window.open(url, '_blank', 'noopener,noreferrer')
}
const openInOSM = () => {
const [latitude, longitude] = (vModel.value || '').split(';')
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`
window.open(url, '_blank')
window.open(url, '_blank', "'noopener,noreferrer'")
}
</script>

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

@ -72,13 +72,7 @@ const clear = () => {
const formatJson = (json: string) => {
try {
json = json
.trim()
.replace(/^\{\s*|\s*\}$/g, '')
.replace(/\n\s*/g, '')
json = `{${json}}`
return json
return JSON.stringify(JSON.parse(json))
} catch (e) {
console.log(e)
return json

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

@ -144,14 +144,14 @@ const selectedTitles = computed(() =>
}
return 0
})
: modelValue.split(',').map((el) => el.trim())
: modelValue.map((el) => el.trim())
: modelValue.split(',')
: modelValue
: [],
)
onMounted(() => {
selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el)
const item = options.value.find((op) => op.title === el || op.title === el?.trim())
const itemIdOrTitle = item?.id || item?.title
if (itemIdOrTitle) {
return [itemIdOrTitle]
@ -165,7 +165,7 @@ watch(
() => modelValue,
() => {
selectedIds.value = selectedTitles.value.flatMap((el) => {
const item = options.value.find((op) => op.title === el)
const item = options.value.find((op) => op.title === el || op.title === el?.trim())
if (item && (item.id || item.title)) {
return [(item.id || item.title)!]
}

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

@ -49,8 +49,6 @@ const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>

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

@ -70,15 +70,19 @@ watch(
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`tel:${vModel}`" target="_blank">
<a
v-else-if="validEmail"
class="text-sm underline hover:opacity-75"
:href="`tel:${vModel}`"
target="_blank"
rel="noopener noreferrer"
>
<LazyCellClampedText :value="vModel" :lines="rowHeight" />
</a>

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

@ -104,7 +104,7 @@ const hasEditRoles = computed(() => isUIAllowed('dataEdit'))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
const vModel = computed({
get: () => tempSelectedOptState.value ?? modelValue?.trim(),
get: () => tempSelectedOptState.value ?? modelValue,
set: (val) => {
if (val && isNewOptionCreateEnabled.value && (options.value ?? []).every((op) => op.title !== val)) {
tempSelectedOptState.value = val
@ -259,7 +259,7 @@ const handleClose = (e: MouseEvent) => {
useEventListener(document, 'click', handleClose, true)
const selectedOpt = computed(() => {
return options.value.find((o) => o.value === vModel.value)
return options.value.find((o) => o.value === vModel.value || o.value === vModel.value?.trim())
})
</script>

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

@ -43,8 +43,6 @@ const focus: VNodeRef = (el) => !isExpandedFormOpen.value && !isEditColumn.value
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>

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

@ -16,6 +16,7 @@ import {
const props = defineProps<{
modelValue?: string | number
isFocus?: boolean
virtual?: boolean
}>()
const emits = defineEmits(['update:modelValue'])
@ -65,6 +66,13 @@ onClickOutside(inputWrapperRef, (e) => {
isVisible.value = false
})
const onDblClick = () => {
if (!props.virtual) return
isVisible.value = true
editEnabled.value = true
}
</script>
<template>
@ -100,15 +108,23 @@ onClickOutside(inputWrapperRef, (e) => {
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText v-else-if="rowHeight" :value="vModel" :lines="rowHeight" class="mr-7" />
<LazyCellClampedText
v-else-if="rowHeight"
:value="vModel"
:lines="rowHeight"
class="mr-7 nc-text-area-clamped-text"
:style="{
'word-break': 'break-word',
'white-space': 'pre-line',
}"
@click="onDblClick"
/>
<span v-else>{{ vModel }}</span>
@ -142,7 +158,7 @@ onClickOutside(inputWrapperRef, (e) => {
<a-textarea
ref="inputRef"
v-model:value="vModel"
class="p-1 !pt-1 !pr-3 !border-0 !border-r-0 !focus:outline-transparent nc-scrollbar-md !text-black"
class="p-1 !pt-1 !pr-3 !border-0 !border-r-0 !focus:outline-transparent nc-scrollbar-md !text-black !cursor-text"
:placeholder="$t('activity.enterText')"
:bordered="false"
:auto-size="{ minRows: 20, maxRows: 20 }"

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

@ -101,6 +101,12 @@ const placeholder = computed(() => {
}
})
const isOpen = computed(() => {
if (readOnly.value) return false
return (readOnly.value || (localState.value && isPk)) && !active.value && !editable.value ? false : open.value
})
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
@ -129,7 +135,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
:placeholder="placeholder"
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
:open="isOpen"
:popup-class-name="`${randomClass} nc-picker-time ${open ? 'active' : ''}`"
@click="open = (active || editable) && !open"
@ok="open = !open"

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

@ -99,8 +99,6 @@ watch(
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.ctrl.z.stop
@keydown.meta.z.stop
@selectstart.capture.stop
@mousedown.stop
/>

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

@ -88,6 +88,12 @@ const placeholder = computed(() => {
}
})
const isOpen = computed(() => {
if (readOnly.value) return false
return (readOnly.value || (localState.value && isPk)) && !active.value && !editable.value ? false : open.value
})
useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
@ -114,7 +120,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
:placeholder="placeholder"
:allow-clear="(!readOnly && !localState && !isPk) || isEditColumn"
:input-read-only="true"
:open="(readOnly || (localState && isPk)) && !active && !editable ? false : open"
:open="isOpen"
:dropdown-class-name="`${randomClass} nc-picker-year ${open ? 'active' : ''}`"
@click="open = (active || editable) && !open"
@change="open = (active || editable) && !open"

6
packages/nc-gui/components/cell/attachment/RenameFile.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { onKeyStroke, onMounted, reactive, ref } from '#imports'
import { onKeyStroke, onMounted, reactive, ref, useI18n } from '#imports'
const props = defineProps<{
title: string
@ -10,6 +10,8 @@ const emit = defineEmits<{
(event: 'cancel'): void
}>()
const { t } = useI18n()
const inputEl = ref()
const visible = ref(true)
@ -29,7 +31,7 @@ function renameFile(fileName: string) {
// }
const rules = {
title: [{ required: true, message: 'title is required.' }],
title: [{ required: true, message: t('labels.titleRequired') }],
}
function onCancel() {

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

@ -195,7 +195,6 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
},
{
files,
json: '{}',
},
)
newAttachments.push(...data)

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

@ -75,7 +75,7 @@ const navigateToSettings = () => {
<div class="gap-x-2 flex flex-row w-full items-center !font-normal">
<GeneralIcon icon="plus" />
<div class="flex">{{ $t('title.newProj') }}</div>
<div class="flex">{{ $t('title.createBase') }}</div>
</div>
</WorkspaceCreateProjectBtn>
</div>

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

@ -85,7 +85,7 @@ onMounted(() => {
class="flex flex-row py-2 px-3 gap-x-2 items-center hover:bg-gray-200 rounded-lg cursor-pointer h-10"
data-testid="nc-sidebar-userinfo"
>
<GeneralUserIcon :email="user?.email" size="base" />
<GeneralUserIcon :email="user?.email" size="base" :name="user?.display_name" />
<div class="flex truncate">
{{ name ? name : user?.email }}
</div>
@ -99,22 +99,33 @@ onMounted(() => {
<span class="menu-btn"> {{ $t('general.logout') }}</span>
</NcMenuItem>
<template v-if="!isMobileMode">
<NcDivider />
<a v-e="['c:nocodb:docs-open']" href="https://docs.nocodb.com" target="_blank" class="!underline-transparent">
<NcMenuItem>
<GeneralIcon icon="help" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.helpCenter') }} </span>
</NcMenuItem>
</a>
<NcMenuItem v-e="['c:auth-token:copy']" @click="onCopy">
<GeneralIcon v-if="isAuthTokenCopied" icon="check" class="group-hover:text-black menu-icon" />
<GeneralIcon v-else icon="copy" class="menu-icon" />
<template v-if="isAuthTokenCopied"> {{ $t('title.copiedAuthToken') }} </template>
<template v-else> {{ $t('title.copyAuthToken') }} </template>
</NcMenuItem>
</template>
<NcDivider />
<a v-e="['c:nocodb:discord']" href="https://discord.gg/5RgZmkW" target="_blank" class="!underline-transparent">
<a
v-e="['c:nocodb:discord']"
href="https://discord.gg/5RgZmkW"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="discord" />
<span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span>
</NcMenuItem>
</a>
<a v-e="['c:nocodb:reddit']" href="https://www.reddit.com/r/NocoDB" target="_blank" class="!underline-transparent">
<a
v-e="['c:nocodb:reddit']"
href="https://www.reddit.com/r/NocoDB"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="reddit" />
<span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
@ -148,12 +159,35 @@ onMounted(() => {
<template v-if="!isMobileMode">
<NcDivider />
<NcMenuItem v-e="['c:auth-token:copy']" @click="onCopy">
<GeneralIcon v-if="isAuthTokenCopied" icon="check" class="group-hover:text-black menu-icon" />
<GeneralIcon v-else icon="copy" class="menu-icon" />
<template v-if="isAuthTokenCopied"> {{ $t('title.copiedAuthToken') }} </template>
<template v-else> {{ $t('title.copyAuthToken') }} </template>
</NcMenuItem>
<a
v-e="['c:nocodb:forum-open']"
href="https://community.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="help" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.forum') }} </span>
</NcMenuItem>
</a>
<a
v-e="['c:nocodb:docs-open']"
href="https://docs.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem>
<GeneralIcon icon="doc" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.docs') }} </span>
</NcMenuItem>
</a>
<NcDivider />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="settings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link>

1
packages/nc-gui/components/dashboard/TreeView/AddNewTableNode.vue

@ -250,6 +250,7 @@ function openTableCreateMagicDialog(sourceId?: string) {
href="https://github.com/nocodb/nocodb/issues/2052"
target="_blank"
class="prose-sm hover:(!text-primary !opacity-100) color-transition nc-base-menu-item group after:(!rounded-b)"
rel="noopener noreferrer"
>
<GeneralIcon icon="openInNew" class="group-hover:text-accent" />
<!-- Request a data source you need? -->

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

@ -2,8 +2,15 @@
import type { ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
const props = defineProps<{
// Prop used to align the dropdown to the left in sidebar
alignLeftLevel: number | undefined
}>()
const { $e } = useNuxtApp()
const alignLeftLevel = toRef(props, 'alignLeftLevel')
const { refreshCommandPalette } = useCommandPalette()
const viewsStore = useViewsStore()
const { loadViews, navigateToView } = viewsStore
@ -16,6 +23,14 @@ const toBeCreateType = ref<ViewTypes>()
const isOpen = ref(false)
const overlayClassName = computed(() => {
if (alignLeftLevel.value === 1) return 'nc-view-create-dropdown nc-view-create-dropdown-left-1'
if (alignLeftLevel.value === 2) return 'nc-view-create-dropdown nc-view-create-dropdown-left-2'
return 'nc-view-create-dropdown'
})
async function onOpenModal({
title = '',
type,
@ -68,6 +83,7 @@ async function onOpenModal({
view,
tableId: table.value.id!,
baseId: base.value.id!,
doNotSwitchTab: true,
})
$e('a:view:create', { view: view.type })
@ -84,7 +100,7 @@ async function onOpenModal({
</script>
<template>
<NcDropdown v-model:visible="isOpen" destroy-popup-on-hide @click.stop="isOpen = true">
<NcDropdown v-model:visible="isOpen" destroy-popup-on-hide :overlay-class-name="overlayClassName" @click.stop="isOpen = true">
<slot />
<template #overlay>
<NcMenu class="max-w-48">
@ -151,3 +167,17 @@ async function onOpenModal({
@apply text-brand-400;
}
</style>
<style lang="scss">
.nc-view-create-dropdown {
@apply !max-w-43 !min-w-43;
}
.nc-view-create-dropdown-left-1 {
@apply !left-18;
}
.nc-view-create-dropdown-left-2 {
@apply !left-23.5;
}
</style>

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

@ -26,6 +26,7 @@ import {
useDialog,
useGlobal,
useI18n,
useNuxtApp,
useRoles,
useRouter,
useTablesStore,
@ -33,7 +34,6 @@ import {
useToggle,
} from '#imports'
import type { NcProject } from '#imports'
import { useNuxtApp } from '#app'
const indicator = h(LoadingOutlined, {
class: '!text-gray-400',
@ -514,7 +514,7 @@ const projectDelete = () => {
@click.stop="
() => {
$e('c:base:api-docs')
openLink(`/api/v1/db/meta/projects/${base.id}/swagger`, appInfo.ncSiteUrl)
openLink(`/api/v2/meta/bases/${base.id}/swagger`, appInfo.ncSiteUrl)
}
"
>

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

@ -3,8 +3,7 @@ import type { BaseType, TableType } from 'nocodb-sdk'
import { storeToRefs } from 'pinia'
import Sortable from 'sortablejs'
import TableNode from './TableNode.vue'
import { useNuxtApp } from '#app'
import { toRef } from '#imports'
import { toRef, useNuxtApp } from '#imports'
const props = withDefaults(
defineProps<{

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

@ -4,8 +4,7 @@ import { toRef } from '@vue/reactivity'
import { message } from 'ant-design-vue'
import { storeToRefs } from 'pinia'
import { useNuxtApp } from '#app'
import { ProjectRoleInj, TreeViewInj, useRoles, useTabs } from '#imports'
import { ProjectRoleInj, TreeViewInj, useNuxtApp, useRoles, useTabs } from '#imports'
const props = withDefaults(
defineProps<{
@ -169,7 +168,6 @@ const isTableOpened = computed(() => {
>
<div class="flex flex-row h-full items-center">
<NcButton
v-if="(table.meta as any)?.hasNonDefaultViews"
v-e="['c:table:toggle-expand']"
type="text"
size="xxsmall"
@ -182,7 +180,6 @@ const isTableOpened = computed(() => {
:class="{ '!rotate-180': isExpanded }"
/>
</NcButton>
<div v-else class="sm:min-w-5.75 xs:min-w-7.5 h-2"></div>
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<div
class="flex items-center nc-table-icon"
@ -231,7 +228,7 @@ const isTableOpened = computed(() => {
</div>
<span
class="nc-tbl-title nc-sidebar-node-title capitalize text-ellipsis overflow-hidden select-none"
class="nc-tbl-title nc-sidebar-node-title text-ellipsis overflow-hidden select-none"
:class="{
'text-black !font-medium': isTableOpened,
}"
@ -296,19 +293,6 @@ const isTableOpened = computed(() => {
</NcMenu>
</template>
</NcDropdown>
<DashboardTreeViewCreateViewBtn v-if="isUIAllowed('viewCreateOrEdit')">
<NcButton
v-e="['c:view:create']"
type="text"
size="xxsmall"
class="nc-create-view-btn nc-sidebar-node-btn"
:class="{
'!md:(visible opacity-100)': openedTableId === table.id,
}"
>
<GeneralIcon icon="plus" class="text-xl leading-5" style="-webkit-text-stroke: 0.15px" />
</NcButton>
</DashboardTreeViewCreateViewBtn>
</div>
</div>
<DlgTableDelete

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

@ -6,6 +6,7 @@ import Sortable from 'sortablejs'
import type { Menu as AntMenu } from 'ant-design-vue'
import {
extractSdkResponseErrorMsg,
isDefaultBase,
message,
onMounted,
parseProp,
@ -32,6 +33,10 @@ const table = inject(SidebarTableInj)!
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
const { activeTableId } = storeToRefs(useTablesStore())
const { isUIAllowed } = useRoles()
const { isMobileMode } = useGlobal()
const { $e } = useNuxtApp()
@ -77,6 +82,13 @@ function markItem(id: string) {
}, 300)
}
const isDefaultSource = computed(() => {
const source = base.value?.sources?.find((b) => b.id === table.value.source_id)
if (!source) return false
return isDefaultBase(source)
})
/** validate view title */
function validate(view: ViewType) {
if (!view.title || view.title.trim().length < 0) {
@ -367,31 +379,62 @@ function onOpenModal({
<template>
<a-menu
v-if="views.length"
ref="menuRef"
:class="{ dragging }"
class="nc-views-menu flex flex-col w-full !border-r-0 !bg-inherit"
:selected-keys="selected"
>
<DashboardTreeViewViewsNode
v-for="view of views"
:id="view.id"
:key="view.id"
:view="view"
:on-validate="validate"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
<DashboardTreeViewCreateViewBtn
v-if="isUIAllowed('viewCreateOrEdit')"
:class="{
'bg-gray-200': isMarked === view.id,
'active': activeView?.id === view.id,
[`nc-${view.type ? viewTypeAlias[view.type] : undefined || view.type}-view-item`]: true,
'!pl-18 !xs:(pl-19.75)': isDefaultSource,
'!pl-23.5 !xs:(pl-27)': !isDefaultSource,
}"
:data-view-id="view.id"
@change-view="changeView"
@open-modal="onOpenModal"
@delete="openDeleteDialog"
@rename="onRename"
@select-icon="setIcon($event, view)"
/>
:align-left-level="isDefaultSource ? 1 : 2"
>
<div
role="button"
class="nc-create-view-btn flex flex-row items-center cursor-pointer rounded-md w-full"
:class="{
'text-brand-500 hover:text-brand-600': activeTableId === table.id,
'text-gray-500 hover:text-brand-500': activeTableId !== table.id,
}"
>
<div class="flex flex-row items-center pl-1.25 !py-1.5 text-inherit">
<GeneralIcon icon="plus" />
<div class="pl-1.75">
{{
$t('general.createEntity', {
entity: $t('objects.view'),
})
}}
</div>
</div>
</div>
</DashboardTreeViewCreateViewBtn>
<template v-if="views.length">
<DashboardTreeViewViewsNode
v-for="view of views"
:id="view.id"
:key="view.id"
:view="view"
:on-validate="validate"
:table="table"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
:class="{
'bg-gray-200': isMarked === view.id,
'active': activeView?.id === view.id,
[`nc-${view.type ? viewTypeAlias[view.type] : undefined || view.type}-view-item`]: true,
}"
:data-view-id="view.id"
@change-view="changeView"
@open-modal="onOpenModal"
@delete="openDeleteDialog"
@rename="onRename"
@select-icon="setIcon($event, view)"
/>
</template>
</a-menu>
</template>

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

@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { TableType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity'
import {
IsLockedInj,
@ -16,6 +16,7 @@ import {
interface Props {
view: ViewType
table: TableType
onValidate: (view: ViewType) => boolean | string
}
@ -47,7 +48,15 @@ const { isUIAllowed } = useRoles()
const base = inject(ProjectInj, ref())
const activeView = inject(ActiveViewInj, ref())
const { activeView } = storeToRefs(useViewsStore())
const { getMeta } = useMetas()
const table = computed(() => props.table)
const injectedTable = ref(table.value)
provide(ActiveViewInj, vModel)
provide(MetaInj, injectedTable)
const isLocked = inject(IsLockedInj, ref(false))
@ -121,28 +130,6 @@ onKeyStroke('Enter', (event) => {
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
/** Duplicate a view */
// todo: This is not really a duplication, maybe we need to implement a true duplication?
function onDuplicate() {
isDropdownOpen.value = false
emits('openModal', {
type: vModel.value.type!,
title: vModel.value.title,
copyViewId: vModel.value.id,
groupingFieldColumnId: (vModel.value.view as KanbanType).fk_grp_col_id!,
})
$e('c:view:copy', { view: vModel.value.type })
}
/** Delete a view */
async function onDelete() {
isDropdownOpen.value = false
emits('delete', vModel.value)
}
/** Rename a view */
async function onRename() {
isDropdownOpen.value = false
@ -189,6 +176,18 @@ function onStopEdit() {
isStopped.value = false
}, 250)
}
const onDelete = () => {
isDropdownOpen.value = false
emits('delete', vModel.value)
}
watch(isDropdownOpen, async () => {
if (!isDropdownOpen.value) return
injectedTable.value = (await getMeta(table.value.id!)) as any
})
</script>
<template>
@ -234,7 +233,7 @@ function onStopEdit() {
<div
v-else
class="nc-sidebar-node-title capitalize text-ellipsis overflow-hidden select-none w-full"
class="nc-sidebar-node-title text-ellipsis overflow-hidden select-none w-full"
data-testid="sidebar-view-title"
:class="{
'font-medium': activeView?.id === vModel.id,
@ -262,25 +261,15 @@ function onStopEdit() {
</NcButton>
<template #overlay>
<NcMenu class="min-w-27" :data-testid="`view-sidebar-view-actions-${vModel.alias || vModel.title}`">
<NcMenuItem v-e="['c:view:rename']" @click.stop="onDblClick">
<GeneralIcon icon="edit" />
<div class="-ml-0.25">{{ $t('general.rename') }}</div>
</NcMenuItem>
<NcMenuItem v-e="['c:view:duplicate']" @click.stop="onDuplicate">
<GeneralIcon icon="duplicate" class="nc-view-copy-icon" />
{{ $t('general.duplicate') }}
</NcMenuItem>
<NcDivider />
<template v-if="!vModel.is_default">
<NcMenuItem v-e="['c:view:delete']" class="!text-red-500 !hover:bg-red-50" @click.stop="onDelete">
<GeneralIcon icon="delete" class="text-sm nc-view-delete-icon" />
<div class="-ml-0.25">{{ $t('general.delete') }}</div>
</NcMenuItem>
</template>
</NcMenu>
<SmartsheetToolbarViewActionMenu
:data-testid="`view-sidebar-view-actions-${vModel.alias || vModel.title}`"
:view="vModel"
:table="table"
in-sidebar
@close-modal="isDropdownOpen = false"
@rename="onRename"
@delete="onDelete"
/>
</template>
</NcDropdown>
</template>

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

@ -18,11 +18,10 @@ import {
useDialog,
useNuxtApp,
useRoles,
useRouter,
useTablesStore,
} from '#imports'
import { useRouter } from '#app'
const { isUIAllowed } = useRoles()
const { $e } = useNuxtApp()

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

@ -89,6 +89,8 @@ function handleMouseMove(e: MouseEvent) {
function onWindowResize() {
viewportWidth.value = window.innerWidth
onResize(currentSidebarSize.value)
}
onMounted(() => {
@ -122,25 +124,69 @@ watch(sidebarState, () => {
onMounted(() => {
handleSidebarOpenOnMobileForNonViews()
})
const remToPx = (rem: number) => {
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
return rem * fontSize
}
function onResize(widthPercent: any) {
if (isMobileMode.value) return
const width = (widthPercent * viewportWidth.value) / 100
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
const widthRem = width / fontSize
if (widthRem < 16) {
sideBarSize.value.old = ((16 * fontSize) / viewportWidth.value) * 100
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
} else if (widthRem > 23.5) {
sideBarSize.value.old = ((23.5 * fontSize) / viewportWidth.value) * 100
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
}
sideBarSize.value.old = widthPercent
sideBarSize.value.current = sideBarSize.value.old
}
const normalizedWidth = computed(() => {
const maxSize = remToPx(23.5)
const minSize = remToPx(16)
if (sidebarWidth.value > maxSize) {
return maxSize
} else if (sidebarWidth.value < minSize) {
return minSize
} else {
return sidebarWidth.value
}
})
</script>
<template>
<Splitpanes
class="nc-sidebar-content-resizable-wrapper w-full h-full"
class="nc-sidebar-content-resizable-wrapper !w-screen h-full"
:class="{
'hide-resize-bar': !isLeftSidebarOpen || sidebarState === 'openStart',
}"
@resize="currentSidebarSize = $event[0].size"
@resize="(event: any) => onResize(event[0].size)"
>
<Pane
min-size="15%"
:size="mobileNormalizedSidebarSize"
max-size="40%"
class="nc-sidebar-splitpane relative !overflow-visible"
class="nc-sidebar-splitpane !sm:max-w-94 relative !overflow-visible flex"
:style="{
width: `${mobileNormalizedSidebarSize}%`,
}"
>
<div
ref="wrapperRef"
class="nc-sidebar-wrapper relative flex flex-col h-full justify-center !min-w-12 absolute overflow-visible"
class="nc-sidebar-wrapper relative flex flex-col h-full justify-center !sm:(max-w-94) absolute overflow-visible"
:class="{
'mobile': isMobileMode,
'minimized-height': !isLeftSidebarOpen,
@ -148,12 +194,19 @@ onMounted(() => {
}"
:style="{
width: sidebarState === 'hiddenEnd' ? '0px' : `${sidebarWidth}px`,
minWidth: sidebarState === 'hiddenEnd' ? '0px' : `${normalizedWidth}px`,
}"
>
<slot name="sidebar" />
</div>
</Pane>
<Pane :size="mobileNormalizedContentSize">
<Pane
:size="mobileNormalizedContentSize"
class="flex-grow"
:style="{
'min-width': `${100 - mobileNormalizedSidebarSize}%`,
}"
>
<slot name="content" />
</Pane>
</Splitpanes>

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

@ -529,7 +529,7 @@ const isEditBaseModalOpen = computed({
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('general.delete') }}
{{ $t('general.remove') }}
</template>
<NcButton
v-if="!source.is_meta && !source.is_local"
@ -554,7 +554,7 @@ const isEditBaseModalOpen = computed({
/>
<GeneralModal v-model:visible="isErdModalOpen" size="large">
<div class="h-[80vh]">
<LazyDashboardSettingsErd :source-id="activeBaseId" />
<LazyDashboardSettingsErd :source-id="activeBaseId" :show-all-columns="false" />
</div>
</GeneralModal>
<GeneralModal v-model:visible="isMetaDataModal" size="medium">
@ -581,7 +581,12 @@ const isEditBaseModalOpen = computed({
<LazyDashboardSettingsBaseAudit :source-id="activeBaseId" @close="isBaseAuditModalOpen = false" />
</div>
</GeneralModal>
<GeneralDeleteModal v-model:visible="isDeleteBaseModalOpen" :entity-name="$t('general.datasource')" :on-delete="deleteBase">
<GeneralDeleteModal
v-model:visible="isDeleteBaseModalOpen"
:entity-name="$t('general.datasource')"
:on-delete="deleteBase"
:delete-label="$t('general.remove')"
>
<template #entity-preview>
<div v-if="toBeDeletedBase" class="flex flex-row items-center py-2 px-3.25 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralBaseLogo :source-type="toBeDeletedBase.type" />

3
packages/nc-gui/components/dashboard/settings/Erd.vue

@ -1,11 +1,12 @@
<script setup lang="ts">
const props = defineProps<{
sourceId: string
showAllColumns?: boolean
}>()
</script>
<template>
<div class="w-full h-full !p-0">
<ErdView :source-id="props.sourceId" />
<ErdView :source-id="props.sourceId" :show-all-columns="props.showAllColumns" />
</div>
</template>

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

@ -16,6 +16,8 @@ import {
useNuxtApp,
} from '#imports'
type Role = 'editor' | 'commenter' | 'viewer'
const props = defineProps<{
sourceId: string
}>()
@ -39,6 +41,12 @@ const tables = ref<any[]>([])
const searchInput = ref('')
const selectAll = ref({
editor: false,
commenter: false,
viewer: false,
})
const filteredTables = computed(() =>
tables.value.filter(
(el) =>
@ -80,15 +88,21 @@ async function saveUIAcl() {
$e('a:proj-meta:ui-acl')
}
const onRoleCheck = (record: any, role: string) => {
const onRoleCheck = (record: any, role: Role) => {
record.disabled[role] = !record.disabled[role]
record.edited = true
selectAll.value[role as Role] = filteredTables.value.every((t) => !t.disabled[role])
}
onMounted(async () => {
if (tables.value.length === 0) {
await loadTableList()
}
for (const role of roles.value) {
selectAll.value[role as Role] = filteredTables.value.every((t) => !t.disabled[role])
}
})
const tableHeaderRenderer = (label: string) => () => h('div', { class: 'text-gray-500' }, label)
@ -96,11 +110,11 @@ const tableHeaderRenderer = (label: string) => () => h('div', { class: 'text-gra
const columns = [
{
title: tableHeaderRenderer(t('labels.tableName')),
name: 'table_name',
name: 'Table Name',
},
{
title: tableHeaderRenderer(t('labels.viewName')),
name: 'view_name',
name: 'View Name',
},
{
title: tableHeaderRenderer(t('objects.roleType.editor')),
@ -118,6 +132,16 @@ const columns = [
width: 120,
},
]
const toggleSelectAll = (role: Role) => {
selectAll.value[role] = !selectAll.value[role]
const enabled = selectAll.value[role]
filteredTables.value.forEach((t) => {
t.disabled[role] = !enabled
t.edited = true
})
}
</script>
<template>
@ -163,12 +187,23 @@ const columns = [
})
"
>
<template #headerCell="{ column }">
<template v-if="['editor', 'commenter', 'viewer'].includes(column.name)">
<div class="flex flex-row gap-x-1">
<NcCheckbox :checked="selectAll[column.name as Role]" @change="() => toggleSelectAll(column.name)" />
<div class="flex capitalize">
{{ column.name }}
</div>
</div>
</template>
<template v-else>{{ column.name }}</template>
</template>
<template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<template #bodyCell="{ record, column }">
<div v-if="column.name === 'table_name'">
<div v-if="column.name === 'Table Name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="{ meta: record.table_meta, type: record.ptype }" class="text-gray-500" />
@ -179,7 +214,7 @@ const columns = [
</div>
</div>
<div v-if="column.name === 'view_name'">
<div v-if="column.name === 'View Name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="record" class="text-gray-500"></GeneralViewIcon>
@ -202,10 +237,10 @@ const columns = [
>
</template>
<a-checkbox
<NcCheckbox
:checked="!record.disabled[role]"
:class="`nc-acl-${record.title}-${role}-chkbox`"
@change="onRoleCheck(record, role)"
:class="`nc-acl-${record.title}-${role}-chkbox !ml-0.25`"
@change="onRoleCheck(record, role as Role)"
/>
</a-tooltip>
</div>

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

@ -66,8 +66,8 @@ const formState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],
@ -77,8 +77,8 @@ const customFormState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],
@ -126,7 +126,7 @@ const validators = computed(() => {
'title': [
{
required: true,
message: 'Source name is required',
message: t('labels.sourceNameRequired'),
},
baseTitleValidator,
],
@ -614,7 +614,6 @@ const toggleModal = (val: boolean) => {
</a-form-item>
<a-divider />
<a-form-item :label="$t('labels.inflection.tableName')">
<a-select
v-model:value="formState.inflection.inflectionTable"

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

@ -61,8 +61,8 @@ const formState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],
@ -72,8 +72,8 @@ const customFormState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'camelize',
inflectionTable: 'camelize',
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],

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

@ -90,8 +90,10 @@ const onStatus = async (status: JobStatus, data?: any) => {
refreshCommandPalette()
// TODO: add tab of the first table
} else if (status === JobStatus.FAILED) {
await loadTables()
goBack.value = true
pushProgress(data.error.message, status)
refreshCommandPalette()
}
}
@ -115,7 +117,10 @@ const { validateInfos } = useForm(syncSource, validators)
const disableImportButton = computed(() => !syncSource.value.details.apiKey || !syncSource.value.details.syncSourceUrlOrId)
const isLoading = ref(false)
async function saveAndSync() {
isLoading.value = true
await createOrUpdate()
await sync()
}
@ -178,6 +183,7 @@ async function listenForUpdates(id?: string) {
}
} else {
listeningForUpdates.value = false
isLoading.value = false
}
},
)
@ -309,6 +315,9 @@ onMounted(async () => {
<a-modal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
:closable="step !== 2"
:keyboard="step !== 2"
:mask-closable="step !== 2"
width="max(30vw, 600px)"
class="p-2"
wrap-class-name="nc-modal-airtable-import"
@ -324,9 +333,10 @@ onMounted(async () => {
<span class="mr-3 pt-2 text-gray-500 text-xs">{{ $t('general.credentials') }}</span>
<!-- Where to find this? -->
<a
href="https://docs.nocodb.com/setup-and-usages/import-airtable-to-sql-database-within-a-minute-for-free/#get-airtable-credentials"
href="https://docs.nocodb.com/bases/import-base-from-airtable#get-airtable-credentials"
class="prose-sm underline text-grey text-xs"
target="_blank"
rel="noopener"
>
{{ $t('msg.info.airtable.credentials') }}
</a>
@ -346,7 +356,7 @@ onMounted(async () => {
<a-input
v-model:value="syncSource.details.syncSourceUrlOrId"
class="nc-input-shared-base"
:placeholder="`${$t('labels.sharedBase')} URL`"
:placeholder="`${$t('labels.sharedBaseUrl')}`"
size="large"
/>
</a-form-item>
@ -411,7 +421,7 @@ onMounted(async () => {
<!-- Questions / Help - Reach out here -->
<div>
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank">
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank" rel="noopener noreferrer">
{{ $t('general.questions') }} / {{ $t('general.help') }} - {{ $t('general.reachOut') }}</a
>
@ -419,7 +429,12 @@ onMounted(async () => {
<!-- This feature is currently in beta and more information can be found here -->
<div>
{{ $t('general.betaNote') }}
<a class="prose-sm" href="https://github.com/nocodb/nocodb/discussions/2122" target="_blank">
<a
class="prose-sm"
href="https://github.com/nocodb/nocodb/discussions/2122"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('general.moreInfo') }}
</a>
.
@ -485,6 +500,7 @@ onMounted(async () => {
v-e="['c:sync-airtable:save-and-sync']"
type="primary"
class="nc-btn-airtable-import"
:loading="isLoading"
:disabled="disableImportButton"
@click="saveAndSync"
>

136
packages/nc-gui/components/dlg/ColumnDuplicate.vue

@ -0,0 +1,136 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { message } from 'ant-design-vue'
import { useVModel } from '#imports'
const props = defineProps<{
modelValue: boolean
column: ColumnType
extra: any
}>()
const emit = defineEmits(['update:modelValue'])
const { api } = useApi()
const dialogShow = useVModel(props, 'modelValue', emit)
const { $e, $poller } = useNuxtApp()
const { activeTable: _activeTable } = storeToRefs(useTablesStore())
const reloadDataHook = inject(ReloadViewDataHookInj)
const { eventBus } = useSmartsheetStoreOrThrow()
const { getMeta } = useMetas()
const meta = inject(MetaInj, ref())
const options = ref({
includeData: true,
})
const optionsToExclude = computed(() => {
const { includeData } = options.value
return {
excludeData: !includeData,
}
})
const isLoading = ref(false)
const reloadTable = async () => {
await getMeta(meta!.value!.id!, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
reloadDataHook?.trigger()
}
const _duplicate = async () => {
try {
isLoading.value = true
const jobData = await api.dbTable.duplicateColumn(props.column.base_id!, props.column.id!, {
options: optionsToExclude.value,
extra: props.extra,
})
$poller.subscribe(
{ id: jobData.id },
async (data: {
id: string
status?: string
data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
reloadTable()
isLoading.value = false
dialogShow.value = false
} else if (data.status === JobStatus.FAILED) {
message.error(`There was an error duplicating the column.`)
reloadTable()
isLoading.value = false
dialogShow.value = false
}
}
},
)
$e('a:column:duplicate')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
isLoading.value = false
dialogShow.value = false
}
}
onKeyStroke('Enter', () => {
// should only trigger this when our modal is open
if (dialogShow.value) {
_duplicate()
}
})
defineExpose({
duplicate: _duplicate,
})
</script>
<template>
<GeneralModal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
:closable="!isLoading"
:mask-closable="!isLoading"
:keyboard="!isLoading"
centered
wrap-class-name="nc-modal-column-duplicate"
:footer="null"
class="!w-[30rem]"
@keydown.esc="dialogShow = false"
>
<div>
<div class="prose-xl font-bold self-center">{{ $t('general.duplicate') }} {{ $t('objects.column') }}</div>
<div class="mt-4">Are you sure you want to duplicate the field?</div>
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div>
<a-divider class="!m-0 !p-0 !my-2" />
<div class="text-xs p-2">
<a-checkbox v-model:checked="options.includeData" :disabled="isLoading">{{ $t('labels.includeData') }}</a-checkbox>
</div>
</div>
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end">
<NcButton v-if="!isLoading" key="back" type="secondary" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton>
<NcButton key="submit" type="primary" :loading="isLoading" @click="_duplicate">{{ $t('general.confirm') }} </NcButton>
</div>
</GeneralModal>
</template>

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

@ -52,6 +52,22 @@ const shortcutList = [
{
title: 'Grid View',
shortcuts: [
{
keys: [renderAltOrOptlKey(), '←'],
behaviour: 'Jump to previous page in this view',
},
{
keys: [renderAltOrOptlKey(), '→'],
behaviour: 'Jump to next page in this view',
},
{
keys: [renderAltOrOptlKey(), '↑'],
behaviour: 'Jump to last page in this view',
},
{
keys: [renderAltOrOptlKey(), '↓'],
behaviour: 'Jump to first page in this view',
},
{
keys: [renderCmdOrCtrlKey(), '←'],
behaviour: 'Jump to leftmost column in this row',

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

@ -46,7 +46,7 @@ onMounted(async () => {
<template>
<GeneralModal v-model:visible="isOpen" size="large">
<div class="h-[80vh]">
<ErdView v-if="!isLoading" :source-id="activeSourceId" :base-id="baseId" />
<ErdView v-if="!isLoading" :source-id="activeSourceId" :base-id="baseId" :show-all-columns="false" />
</div>
</GeneralModal>
</template>

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

@ -31,12 +31,12 @@ import {
useBase,
useGlobal,
useI18n,
useNuxtApp,
useVModel,
} from '#imports'
// import worker script according to the doc of Vite
import importWorkerUrl from '~/workers/importWorker?worker&url'
import { useNuxtApp } from '#app'
interface Props {
modelValue: boolean
@ -172,7 +172,9 @@ const disablePreImportButton = computed(() => {
}
})
const disableImportButton = computed(() => !templateEditorRef.value?.isValid)
const isError = ref(false)
const disableImportButton = computed(() => !templateEditorRef.value?.isValid || isError.value)
const disableFormatJsonButton = computed(() => !jsonEditorRef.value?.isValid)
@ -530,6 +532,14 @@ async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
preImportLoading.value = false
}
}
const onError = () => {
isError.value = true
}
const onChange = () => {
isError.value = false
}
</script>
<template>
@ -558,6 +568,8 @@ async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
:import-worker="importWorker"
class="nc-quick-import-template-editor"
@import="handleImport"
@error="onError"
@change="onChange"
/>
<a-tabs v-else v-model:activeKey="activeKey" hide-add type="editable-card" tab-position="top">

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

@ -110,11 +110,11 @@ watch(
)
function init() {
form.title = `Untitled ${capitalize(typeAlias.value)}`
form.title = `${capitalize(typeAlias.value)}`
const repeatCount = views.value.filter((v) => v.title.startsWith(form.title)).length
if (repeatCount) {
form.title = `${form.title} ${repeatCount}`
form.title = `${form.title}-${repeatCount}`
}
if (selectedViewId.value) {
@ -308,7 +308,7 @@ onMounted(async () => {
<NcSelect
v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select"
:disabled="groupingFieldColumnId || isMetaLoading"
:disabled="isMetaLoading"
:loading="isMetaLoading"
:options="viewSelectFieldOptions"
:placeholder="$t('placeholder.selectGroupField')"
@ -325,7 +325,7 @@ onMounted(async () => {
v-model:value="form.fk_geo_data_col_id"
class="w-full"
:options="viewSelectFieldOptions"
:disabled="groupingFieldColumnId || isMetaLoading"
:disabled="isMetaLoading"
:loading="isMetaLoading"
:placeholder="$t('placeholder.selectGeoField')"
:not-found-content="$t('placeholder.selectGeoFieldNotFound')"

4
packages/nc-gui/components/dlg/share-and-collaborate/Collaborate.vue

@ -58,7 +58,7 @@ watch(
v-bind="validateInfos.emails"
validate-trigger="onBlur"
name="emails"
:rules="[{ required: true, message: 'Please input email' }]"
:rules="[{ required: true, message: t('msg.plsInputEmail') }]"
>
<a-input
v-model:value="invitationUsersData.emails"
@ -72,7 +72,7 @@ watch(
</div>
<div class="flex flex-col w-1/5">
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]">
<a-form-item name="role" :rules="[{ required: true, message: t('msg.roleRequired') }]">
<a-select
v-model:value="invitationUsersData.role"
class="!rounded-md !bg-white"

5
packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue

@ -341,29 +341,24 @@ const isPublicShareDisabled = computed(() => {
</div>
<div v-if="activeView?.type === ViewTypes.FORM" class="flex flex-row justify-between">
<!-- use RTL orientation in form - todo: i18n -->
<div class="text-black">{{ $t('activity.surveyMode') }}</div>
<a-switch
v-model:checked="surveyMode"
v-e="['c:share:view:surver-mode:toggle']"
data-testid="nc-modal-share-view__surveyMode"
>
<!-- todo i18n -->
</a-switch>
</div>
<div v-if="activeView?.type === ViewTypes.FORM && isEeUI" class="flex flex-row justify-between">
<!-- use RTL orientation in form - todo: i18n -->
<div class="text-black">{{ $t('activity.rtlOrientation') }}</div>
<a-switch
v-model:checked="withRTL"
v-e="['c:share:view:rtl-orientation:toggle']"
data-testid="nc-modal-share-view__RTL"
>
<!-- todo i18n -->
</a-switch>
</div>
<div v-if="activeView?.type === ViewTypes.FORM" class="flex flex-col justify-between gap-y-1 bg-gray-50 rounded-md">
<!-- todo: i18n -->
<div class="flex flex-row justify-between">
<div class="text-black">{{ $t('activity.useTheme') }}</div>
<a-switch

4
packages/nc-gui/components/dlg/share-and-collaborate/View.vue

@ -118,11 +118,11 @@ watch(showShareModal, (val) => {
>
<div v-if="isInvitationLinkCopied" class="flex flex-row items-center gap-x-1">
<MdiTick class="h-3.5" />
Copied invite link
{{ $t('activity.copiedInviteLink') }}
</div>
<div v-else class="flex flex-row items-center gap-x-1">
<MdiContentCopy class="h-3.3" />
Copy invite link
{{ $t('activity.copyInviteLink') }}
</div>
</a-button>
</div>

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

@ -59,11 +59,14 @@ const loadMetaOfTablesNotInMetas = async (localTables: TableType[]) => {
const populateTables = async () => {
let localTables: TableType[] = []
if (props.table) {
// use getMeta method to load meta since it will get meta if not loaded already
const tableMeta = await getMeta(props.table!.id!)
// if table is provided only get the table and its related tables
localTables = baseTables.value.filter(
(t) =>
t.id === props.table?.id ||
metas.value[props.table!.id!].columns?.find((column) => {
tableMeta.columns?.find((column) => {
return isLinksOrLTAR(column.uidt) && (column.colOptions as LinkToAnotherRecordType)?.fk_related_model_id === t.id
}),
)

37
packages/nc-gui/components/general/CopyUrl.vue

@ -13,7 +13,7 @@ const isCopied = ref({
})
const openUrl = async () => {
window.open(url.value, '_blank')
window.open(url.value, '_blank', 'noopener,noreferrer')
}
const embedHtml = async () => {
@ -40,18 +40,29 @@ const copyUrl = async () => {
<div class="overflow-hidden whitespace-nowrap text-gray-500">{{ url }}</div>
</div>
<div class="flex flex-row gap-x-1">
<div class="button" @click="openUrl">
<RiExternalLinkLine class="h-3.75" />
</div>
<div
class="button"
:class="{
'!text-gray-300 !border-gray-200 !cursor-not-allowed': isCopied.embed,
}"
@click="embedHtml"
>
<MdiCodeTags class="h-4" />
</div>
<NcTooltip>
<template #title>
{{ $t('activity.openInANewTab') }}
</template>
<div class="button" @click="openUrl">
<RiExternalLinkLine class="h-3.75" />
</div>
</NcTooltip>
<NcTooltip>
<template #title>
{{ $t('activity.copyIFrameCode') }}
</template>
<div
class="button"
:class="{
'!text-gray-300 !border-gray-200 !cursor-not-allowed': isCopied.embed,
}"
@click="embedHtml"
>
<MdiCodeTags class="h-4" />
</div>
</NcTooltip>
<div class="button" data-testid="docs-share-page-copy-link" @click="copyUrl">
<MdiCheck v-if="isCopied.link" class="h-3.5" />
<MdiContentCopy v-else class="h-3.5" />

15
packages/nc-gui/components/general/DeleteModal.vue

@ -5,6 +5,7 @@ const props = defineProps<{
visible: boolean
entityName: string
onDelete: () => Promise<void>
deleteLabel?: string | undefined
}>()
const emits = defineEmits(['update:visible'])
@ -12,6 +13,10 @@ const visible = useVModel(props, 'visible', emits)
const isLoading = ref(false)
const { t } = useI18n()
const deleteLabel = computed(() => props.deleteLabel ?? t('general.delete'))
const onDelete = async () => {
isLoading.value = true
try {
@ -43,11 +48,15 @@ onKeyStroke('Enter', () => {
<GeneralModal v-model:visible="visible" size="small" centered>
<div class="flex flex-col p-6">
<div class="flex flex-row pb-2 mb-4 font-medium text-lg border-b-1 border-gray-50 text-gray-800">
{{ $t('general.delete') }} {{ props.entityName }}
{{ deleteLabel }} {{ props.entityName }}
</div>
<div class="mb-3 text-gray-800">
{{ $t('msg.areYouSureUWantTo') }}<span class="ml-1">{{ props.entityName.toLowerCase() }}?</span>
{{
$t('msg.areYouSureUWantToDeleteLabel', {
deleteLabel: deleteLabel.toLowerCase(),
})
}}<span class="ml-1">{{ props.entityName.toLowerCase() }}?</span>
</div>
<slot name="entity-preview"></slot>
@ -65,7 +74,7 @@ onKeyStroke('Enter', () => {
data-testid="nc-delete-modal-delete-btn"
@click="onDelete"
>
{{ `${$t('general.delete')} ${props.entityName}` }}
{{ `${deleteLabel} ${props.entityName}` }}
<template #loading>
{{ $t('general.deleting') }}
</template>

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

@ -34,9 +34,8 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
<template>
<a-tooltip placement="left">
<!-- todo: i18n -->
<template #title>
<span class="text-xs">{{ isSidebarsOpen ? 'Full width' : 'Exit full width' }}</span>
<span class="text-xs">{{ isSidebarsOpen ? $t('activity.fullWidth') : $t('activity.exitFullWidth') }}</span>
</template>
<div
v-e="['c:toolbar:fullscreen']"

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

@ -10,14 +10,12 @@ import { iconMap } from '#imports'
>
<div
class="flex justify-center items-center rounded-l-[3px] w-full cursor-pointer px-2 py-1 !text-current !no-underline text-primary border-1 border-[#cdd1d6] bg-[#EFF2F6] hover:bg-[#e9ebef] m-0"
target="_blank"
>
<component :is="iconMap.cloud" class="mt-[1px] text-black font-bold" />
<div class="px-1 text-xs font-bold text-gray-800">Join</div>
<div class="px-1 text-xs font-bold text-gray-800">{{ $t('general.join') }}</div>
</div>
<div
class="group flex justify-center items-center rounded-r-[3px] w-full cursor-pointer px-1 py-1 text-primary border-r-1 border-b-1 border-t-1 border-[#cdd1d6] m-0"
target="_blank"
>
<div class="px-1 text-xs font-semibold group-hover:text-[#0a69da] text-gray-900">NocoDB Cloud</div>
</div>

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

@ -40,7 +40,7 @@ const encodedSummary = computed(() => encodeURIComponent(summary || summaryArr[M
const fbHashTags = computed(() => hashTags && `%23${hashTags}`)
const openUrl = (url: string) => {
window.open(url, '_blank')
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>

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

@ -40,7 +40,7 @@ useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const copySharedBase = async () => {
const baseUrl = getMainUrl()
window.open(`${baseUrl || ''}#/copy-shared-base?base=${route.params.baseId}`, '_blank')
window.open(`${baseUrl || ''}#/copy-shared-base?base=${route.params.baseId}`, '_blank', 'noopener,noreferrer')
}
</script>

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

@ -4,7 +4,7 @@ import { iconMap, useI18n } from '#imports'
const { locale } = useI18n()
const open = (url: string) => {
window.open(url, '_blank')
window.open(url, '_blank', 'noopener,noreferrer')
}
const isZhLang = computed(() => locale.value.startsWith('zh'))

18
packages/nc-gui/components/general/UserIcon.vue

@ -12,23 +12,21 @@ const props = withDefaults(
},
)
const emailProp = toRef(props, 'email')
const size = computed(() => props.size || 'medium')
const displayName = computed(() => props.name ?? '')
const email = computed(() => props?.email ?? '')
const backgroundColor = computed(() => {
// in comments we need to generate user icon from email
if (emailProp.value.length) {
return stringToColor(emailProp.value)
if (email.value.length) {
return stringToColor(email.value)
}
return props.email ? stringToColor(props.email) : '#FFFFFF'
return email.value ? stringToColor(email.value) : '#FFFFFF'
})
const size = computed(() => props.size || 'medium')
const displayName = computed(() => props.email ?? '')
const email = computed(() => props.name ?? props?.email ?? '')
const usernameInitials = computed(() => {
const displayNameSplit = displayName.value?.split(' ').filter((name) => name) ?? []

1
packages/nc-gui/components/general/language/Menu.vue

@ -26,6 +26,7 @@ async function changeLanguage(lang: string) {
href="https://docs.nocodb.com/engineering/translation/#how-to-contribute--for-community-members"
target="_blank"
class="caption nc-base-menu-item py-2 text-primary underline hover:opacity-75"
rel="noopener"
>
{{ $t('activity.translate') }}
</a>

10
packages/nc-gui/components/nc/Dropdown.vue

@ -47,6 +47,14 @@ onClickOutside(overlayWrapperDomRef, () => {
visible.value = false
})
const onVisibleUpdate = (event: any) => {
if (visible !== undefined) {
visible.value = event
} else {
emits('update:visible', event)
}
}
</script>
<template>
@ -54,7 +62,7 @@ onClickOutside(overlayWrapperDomRef, () => {
:visible="visible"
:trigger="trigger"
:overlay-class-name="overlayClassNameComputed"
@update:visible="visible !== undefined ? (visible = $event) : undefined"
@update:visible="onVisibleUpdate"
>
<slot />

121
packages/nc-gui/components/nc/Pagination.vue

@ -1,10 +1,16 @@
<script setup lang="ts">
import NcTooltip from '~/components/nc/Tooltip.vue'
const props = defineProps<{
current: number
total: number
pageSize: number
entityName?: string
mode?: 'simple' | 'full'
prevPageTooltip?: string
nextPageTooltip?: string
firstPageTooltip?: string
lastPageTooltip?: string
}>()
const emits = defineEmits(['update:current', 'update:pageSize'])
@ -42,40 +48,52 @@ const goToFirstPage = () => {
}
const pagesList = computed(() => {
return Array.from({ length: totalPages.value }, (_, i) => i + 1)
return Array.from({ length: totalPages.value }, (_, i) => ({
value: i + 1,
label: i + 1,
}))
})
</script>
<template>
<div class="nc-pagination flex flex-row items-center gap-x-2">
<NcButton
v-if="mode === 'full'"
v-e="[`a:pagination:${entityName}:first-page`]"
class="first-page"
type="secondary"
size="small"
:disabled="current === 1"
@click="goToFirstPage"
>
<GeneralIcon icon="doubleLeftArrow" />
</NcButton>
<NcButton
v-e="[`a:pagination:${entityName}:prev-page`]"
class="prev-page"
type="secondary"
size="small"
:disabled="current === 1"
@click="changePage({ increase: false })"
>
<GeneralIcon icon="arrowLeft" />
</NcButton>
<component :is="props.firstPageTooltip && mode === 'full' ? NcTooltip : 'div'" v-if="mode === 'full'">
<template v-if="props.firstPageTooltip" #title>
{{ props.firstPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:first-page`]"
class="first-page"
type="secondary"
size="small"
:disabled="current === 1"
@click="goToFirstPage"
>
<GeneralIcon icon="doubleLeftArrow" />
</NcButton>
</component>
<component :is="props.prevPageTooltip && mode === 'full' ? NcTooltip : 'div'">
<template v-if="props.prevPageTooltip" #title>
{{ props.prevPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:prev-page`]"
class="prev-page"
type="secondary"
size="small"
:disabled="current === 1"
@click="changePage({ increase: false })"
>
<GeneralIcon icon="arrowLeft" />
</NcButton>
</component>
<div v-if="!isMobileMode" class="text-gray-600">
<a-select v-model:value="current" class="!mr-[2px]" virtual>
<a-select v-model:value="current" class="!mr-[2px]" :options="pagesList">
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-500 nc-select-expand-btn" />
</template>
<a-select-option v-for="p of pagesList" :key="`p-${p}`" @click="changePage({ set: p })">{{ p }}</a-select-option>
</a-select>
<span class="mx-1"> {{ mode !== 'full' ? '/' : 'of' }} </span>
<span class="total">
@ -83,28 +101,37 @@ const pagesList = computed(() => {
</span>
</div>
<NcButton
v-e="[`a:pagination:${entityName}:next-page`]"
class="next-page"
type="secondary"
size="small"
:disabled="current === totalPages"
@click="changePage({ increase: true })"
>
<GeneralIcon icon="arrowRight" />
</NcButton>
<NcButton
v-if="mode === 'full'"
v-e="[`a:pagination:${entityName}:last-page`]"
class="last-page"
type="secondary"
size="small"
:disabled="current === totalPages"
@click="goToLastPage"
>
<GeneralIcon icon="doubleRightArrow" />
</NcButton>
<component :is="props.nextPageTooltip && mode === 'full' ? NcTooltip : 'div'">
<template v-if="props.nextPageTooltip" #title>
{{ props.nextPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:next-page`]"
class="next-page"
type="secondary"
size="small"
:disabled="current === totalPages"
@click="changePage({ increase: true })"
>
<GeneralIcon icon="arrowRight" />
</NcButton>
</component>
<component :is="props.lastPageTooltip && mode === 'full' ? NcTooltip : 'div'" v-if="mode === 'full'">
<template v-if="props.lastPageTooltip" #title>
{{ props.lastPageTooltip }}
</template>
<NcButton
v-e="[`a:pagination:${entityName}:last-page`]"
class="last-page"
type="secondary"
size="small"
:disabled="current === totalPages"
@click="goToLastPage"
>
<GeneralIcon icon="doubleRightArrow" />
</NcButton>
</component>
</div>
</template>

7
packages/nc-gui/components/notification/Card.vue

@ -25,8 +25,7 @@ const groupType = computed({
<div class="p-3" @click.stop>
<div class="flex items-center">
<span class="text-md font-medium text-[#212121]">
<!-- todo: i18n -->
Notification
{{ $t('general.notification') }}
</span>
<div class="flex-grow"></div>
<div
@ -34,7 +33,7 @@ const groupType = computed({
class="cursor-pointer text-xs text-gray-500 hover:text-primary"
@click.stop="notificationStore.markAllAsRead"
>
Mark all as read
{{ $t('activity.markAllAsRead') }}
</div>
</div>
</div>
@ -48,7 +47,7 @@ const groupType = computed({
>
<template v-if="!notifications?.length">
<div class="flex flex-col gap-2 items-center justify-center">
<div class="text-sm text-gray-400">You have no new notifications</div>
<div class="text-sm text-gray-400">{{ $t('msg.noNewNotifications') }}</div>
<GeneralIcon icon="inbox" class="!text-40px text-gray-400" />
</div>
</template>

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

@ -190,7 +190,7 @@ onMounted(async () => {
class="user-row flex flex-row border-b-1 py-1 min-h-14 items-center"
>
<div class="flex gap-3 items-center users-email-grid">
<GeneralUserIcon size="base" :name="collab.email" :email="collab.email" />
<GeneralUserIcon size="base" :email="collab.email" />
<span class="truncate">
{{ collab.email }}
</span>

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

@ -73,9 +73,11 @@ watch(
>
<div class="flex flex-row items-center gap-x-3">
<GeneralOpenLeftSidebarBtn />
<GeneralProjectIcon :type="openedProject?.type" />
<div class="flex font-medium text-sm capitalize">
{{ openedProject?.title }}
<div class="flex flex-row items-center h-full gap-x-2.5">
<GeneralProjectIcon :type="openedProject?.type" />
<div class="flex font-medium text-sm capitalize">
{{ openedProject?.title }}
</div>
</div>
</div>
<LazyGeneralShareProject />

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

@ -26,11 +26,9 @@ const sizeSelect = computed(() => props.size)
const roleProperties = computed(() => {
const role = roleRef.value
const color = RoleColors[role]
const icon = RoleIcons[role]
const label = RoleLabels[role]
return {
color,
icon,
@ -63,7 +61,7 @@ const roleProperties = computed(() => {
>
<GeneralIcon :icon="roleProperties.icon" />
<span class="flex whitespace-nowrap">
{{ roleProperties.label }}
{{ $t(`objects.roleType.${roleProperties.label}`) }}
</span>
<GeneralIcon v-if="clickableRef" icon="arrowDown" />
</div>

4
packages/nc-gui/components/shared-view/Gallery.vue

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ActiveViewInj, FieldsInj, IsPublicInj, MetaInj, ReadonlyInj, ReloadViewDataHookInj } from '#imports'
const { sharedView, meta, sorts, nestedFilters } = useSharedView()
const { sharedView, meta, nestedFilters } = useSharedView()
const reloadEventHook = createEventHook()
@ -19,7 +19,7 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
</script>
<template>

6
packages/nc-gui/components/shared-view/Grid.vue

@ -17,13 +17,15 @@ import {
useSharedView,
} from '#imports'
const { sharedView, meta, sorts, nestedFilters } = useSharedView()
const { sharedView, meta, nestedFilters } = useSharedView()
const { signedIn } = useGlobal()
const { loadProject } = useBase()
const { isLocked } = useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
const { isLocked } = useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView)
const reloadEventHook = createEventHook()

4
packages/nc-gui/components/shared-view/Kanban.vue

@ -9,7 +9,7 @@ import {
useProvideKanbanViewStore,
} from '#imports'
const { sharedView, meta, sorts, nestedFilters } = useSharedView()
const { sharedView, meta, nestedFilters } = useSharedView()
const reloadEventHook = createEventHook()
@ -27,7 +27,7 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView, true)
</script>

4
packages/nc-gui/components/shared-view/Map.vue

@ -9,7 +9,7 @@ import {
useProvideMapViewStore,
} from '#imports'
const { sharedView, meta, sorts, nestedFilters } = useSharedView()
const { sharedView, meta, nestedFilters } = useSharedView()
const reloadEventHook = createEventHook()
@ -27,7 +27,7 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetStore(sharedView, meta, true, sorts, nestedFilters)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideMapViewStore(meta, sharedView, true)
</script>

1
packages/nc-gui/components/smartsheet/ApiSnippet.vue

@ -210,6 +210,7 @@ watch(activeLang, (newLang) => {
class="px-4 py-2 ! rounded shadow"
href="https://angel.co/company/nocodb"
target="_blank"
rel="noopener noreferrer"
@click.stop
>
🚀 {{ $t('labels.weAreHiring') }}! 🚀

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

@ -216,7 +216,7 @@ onUnmounted(() => {
>
<template v-if="column">
<template v-if="intersected">
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" />
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />

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

@ -67,7 +67,7 @@ reloadEventHook.on(async () => {
const { showAll, hideAll, saveOrUpdate } = useViewColumnsOrThrow()
const { syncLTARRefs, row } = useProvideSmartsheetRowStore(
const { state, row } = useProvideSmartsheetRowStore(
meta,
ref({
row: formState,
@ -124,11 +124,7 @@ async function submitForm() {
if (e.errorFields.length) return
}
const insertedRowData = await insertRow({ row: formState, oldRow: {}, rowMeta: { new: true } })
if (insertedRowData) {
await syncLTARRefs(insertedRowData)
}
await insertRow({ row: { ...formState, ...state.value }, oldRow: {}, rowMeta: { new: true } })
submitted.value = true
}

8
packages/nc-gui/components/smartsheet/Pagination.vue

@ -62,6 +62,10 @@ const page = computed({
})
const isRTLLanguage = computed(() => isRtlLang(locale.value as keyof typeof Language))
const renderAltOrOptlKey = () => {
return isMac() ? '⌥' : 'ALT'
}
</script>
<template>
@ -107,6 +111,10 @@ const isRTLLanguage = computed(() => isRtlLang(locale.value as keyof typeof Lang
:class="{ 'rtl-pagination': isRTLLanguage }"
:total="+count"
entity-name="grid"
:prev-page-tooltip="`${renderAltOrOptlKey()}+←`"
:next-page-tooltip="`${renderAltOrOptlKey()}+→`"
:first-page-tooltip="`${renderAltOrOptlKey()}+↓`"
:last-page-tooltip="`${renderAltOrOptlKey()}+↑`"
/>
<div v-else class="mx-auto flex items-center mt-n1" style="max-width: 250px">
<span class="text-xs" style="white-space: nowrap"> Change page:</span>

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

@ -11,7 +11,6 @@ import {
toRef,
useProvideSmartsheetRowStore,
useSmartsheetStoreOrThrow,
watch,
} from '#imports'
const props = defineProps<{
@ -24,16 +23,6 @@ const { meta } = useSmartsheetStoreOrThrow()
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef } = useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow)
// on changing isNew(new record insert) status sync LTAR cell values
watch(isNew, async (nextVal, prevVal) => {
if (prevVal && !nextVal) {
await syncLTARRefs(currentRow.value.row)
// update row values without invoking api
currentRow.value.row = { ...currentRow.value.row, ...state.value }
currentRow.value.oldRow = { ...currentRow.value.row, ...state.value }
}
})
const reloadViewDataTrigger = inject(ReloadViewDataHookInj)!
// override reload trigger and use it to reload row

7
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -51,13 +51,6 @@ const { allowCSVDownload } = useSharedView()
'w-full': isMobileMode,
}"
/>
<template v-if="!isMobileMode">
<LazySmartsheetToolbarViewActions
v-if="(isGrid || isGallery || isKanban || isMap) && !isPublic && isUIAllowed('dataInsert')"
:show-system-fields="false"
/>
</template>
</template>
</div>
</template>

6
packages/nc-gui/components/smartsheet/column/DecimalOptions.vue

@ -34,7 +34,11 @@ onMounted(() => {
<template>
<a-form-item :label="$t('placeholder.precision')">
<a-select v-model:value="vModel.meta.precision" dropdown-class-name="nc-dropdown-decimal-format">
<a-select
v-if="vModel.meta?.precision"
v-model:value="vModel.meta.precision"
dropdown-class-name="nc-dropdown-decimal-format"
>
<a-select-option v-for="(format, i) of precisionFormats" :key="i" :value="format">
<div class="flex flex-row items-center">
<div class="text-xs">

13
packages/nc-gui/components/smartsheet/column/DefaultValue.vue

@ -24,6 +24,8 @@ useProvideSmartsheetRowStore(meta, rowRef)
const cdfValue = ref<string | null>(null)
const editEnabled = ref(false)
const updateCdfValue = (cdf: string | null) => {
vModel.value.cdf = cdf
cdfValue.value = vModel.value.cdf
@ -37,13 +39,20 @@ onMounted(() => {
<template>
<div class="!my-3 text-xs">{{ $t('placeholder.defaultValue') }}</div>
<div class="flex flex-row gap-2">
<div class="border-1 flex items-center w-full px-3 my-[-4px] border-gray-300 rounded-md">
<div
class="border-1 flex items-center w-full px-3 my-[-4px] border-gray-300 rounded-md"
:class="{
'!border-brand-500': editEnabled,
}"
>
<LazySmartsheetCell
:edit-enabled="true"
:model-value="cdfValue"
:column="vModel"
:edit-enabled="true"
class="!border-none"
@update:cdf="updateCdfValue"
@update:edit-enabled="editEnabled = $event"
@click="editEnabled = true"
/>
<component
:is="iconMap.close"

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

@ -79,6 +79,8 @@ const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [UITypes.LinkToAnotherRecord, UITypes.Lookup, UITypes.Rollup, UITypes.Links]
const geoDataToggleCondition = (t: { name: UITypes }) => {
if (!appInfo.value.ee) return true
return betaFeatureToggleState.show ? betaFeatureToggleState.show : !t.name.includes(UITypes.GeoData)
}
@ -230,7 +232,7 @@ if (props.fromTableExplorer) {
<input
ref="antInput"
v-model="formState.title"
class="flex flex-grow text-lg font-bold outline-none bg-inherit"
class="flex flex-grow nc-fields-input text-lg font-bold outline-none bg-inherit"
:contenteditable="true"
/>
</div>

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

@ -747,7 +747,12 @@ onMounted(() => {
placeholder2: '{column_name}',
})
}}
<a class="prose-sm" href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features" target="_blank">
<a
class="prose-sm"
href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features"
target="_blank"
rel="noopener"
>
{{ $t('msg.formula.hintEnd') }}
</a>
</div>

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

@ -120,12 +120,6 @@ onMounted(() => {
}
})
const optionChanged = (changedId: string) => {
if (changedId && changedId === defaultOption.value?.id) {
vModel.value.cdf = defaultOption.value.title
}
}
const getNextColor = () => {
let tempColor = colors.value[0]
if (options.value.length && options.value[options.value.length - 1].color) {
@ -174,11 +168,21 @@ const addNewOption = () => {
// }
const syncOptions = () => {
vModel.value.colOptions.options = renderedOptions.value.filter((op) => op.status !== 'remove')
vModel.value.colOptions.options = options.value.filter((op) => op.status !== 'remove')
}
const removeRenderedOption = (index: number) => {
renderedOptions.value[index].status = 'remove'
const renderedOption = renderedOptions.value[index]
const option = options.value[loadedOptionAnchor.value + index]
renderedOption.status = 'remove'
option.status = 'remove'
renderedOption.status = 'remove'
if (option) {
option.status = 'remove'
}
syncOptions()
const optionId = renderedOptions.value[index]?.id
@ -191,8 +195,20 @@ const removeRenderedOption = (index: number) => {
}
}
const optionChanged = (changedId: string) => {
if (changedId && changedId === defaultOption.value?.id) {
vModel.value.cdf = defaultOption.value.title
}
syncOptions()
}
const undoRemoveRenderedOption = (index: number) => {
renderedOptions.value[index].status = undefined
const renderedOption = renderedOptions.value[index]
const option = options.value[loadedOptionAnchor.value + index]
renderedOption.status = undefined
option.status = undefined
syncOptions()
const optionId = renderedOptions.value[index]?.id

2
packages/nc-gui/components/smartsheet/details/Erd.vue

@ -24,7 +24,7 @@ const indicator = h(LoadingOutlined, {
</div>
<Suspense v-else>
<LazyErdView :table="activeTable" :source-id="activeTable?.source_id" :show-all-columns="false" />
<LazyErdView :table="activeTable" :source-id="activeTable?.source_id" show-all-columns />
<template #fallback>
<div class="h-full w-full flex flex-col justify-center items-center mt-28">
<a-spin size="large" :indicator="indicator" />

298
packages/nc-gui/components/smartsheet/details/Fields.vue

@ -1,9 +1,10 @@
<script setup lang="ts">
import { diff } from 'deep-object-diff'
import { message } from 'ant-design-vue'
import { UITypes, isSystemColumn } from 'nocodb-sdk'
import { UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import Draggable from 'vuedraggable'
import type { ColumnType, SelectOptionsType, TableType } from 'nocodb-sdk'
import { onKeyDown, useMagicKeys } from '@vueuse/core'
import type { ColumnType, SelectOptionsType } from 'nocodb-sdk'
import { Icon } from '@iconify/vue'
import { type Field, getUniqueColumnName, ref, useSmartsheetStoreOrThrow } from '#imports'
@ -39,12 +40,16 @@ const { getMeta } = useMetas()
const { meta, view } = useSmartsheetStoreOrThrow()
const { openedViewsTab } = storeToRefs(useViewsStore())
const moveOps = ref<moveOp[]>([])
const visibilityOps = ref<fieldsVisibilityOps[]>([])
const fieldsListWrapperDomRef = ref<HTMLElement>()
const { copy } = useClipboard()
const { fields: viewFields, toggleFieldVisibility, loadViewColumns, isViewColumnsLoading } = useViewColumnsOrThrow()
const loading = ref(false)
@ -53,6 +58,8 @@ const columnsHash = ref<string>()
const newFields = ref<TableExplorerColumn[]>([])
const isFieldIdCopied = ref(false)
const compareCols = (a?: TableExplorerColumn, b?: TableExplorerColumn) => {
if (a?.id && b?.id) {
return a.id === b.id
@ -481,7 +488,15 @@ const clearChanges = () => {
changeField()
}
const isColumnsValid = computed(() => fields.value.every((f) => isColumnValid(f)))
const saveChanges = async () => {
if (!isColumnsValid.value) {
message.error('Please complete the configuration of all fields before saving')
return
} else if (!loading.value && ops.value.length < 1 && moveOps.value.length < 1 && visibilityOps.value.length < 1) {
return
}
try {
if (!meta.value?.id) return
@ -569,12 +584,111 @@ const toggleVisibility = async (checked: boolean, field: Field) => {
})
}
const isColumnsValid = computed(() => fields.value.every((f) => isColumnValid(f)))
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
onMounted(async () => {
if (cmdOrCtrl && e.key.toLowerCase() === 's') {
if (openedViewsTab.value !== 'field') return
e.preventDefault()
return
}
// For Windows and mac
if ((e.altKey && e.key.toLowerCase() === 'c') || (e.altKey && e.code === 'KeyC')) {
if (openedViewsTab.value !== 'field') return
e.preventDefault()
addField()
}
})
const renderCmdOrCtrlKey = () => {
return isMac() ? '⌘' : 'Ctrl'
}
const renderAltOrOptlKey = () => {
return isMac() ? '⌥' : 'ALT'
}
onKeyDown('ArrowDown', () => {
const index = fields.value.findIndex((f) => compareCols(f, activeField.value))
if (index === -1) changeField(fields.value[0])
else if (index === fields.value.length - 1) changeField(fields.value[0])
else changeField(fields.value[index + 1])
})
onKeyDown('ArrowUp', () => {
const index = fields.value.findIndex((f) => compareCols(f, activeField.value))
if (index === -1) changeField(fields.value[0])
else if (index === 0) changeField(fields.value[fields.value.length - 1])
else changeField(fields.value[index - 1])
})
onKeyDown('Delete', () => {
if (document.activeElement?.tagName === 'INPUT') return
const isDeletedField = fieldStatus(activeField.value) === 'delete'
if (!isDeletedField && activeField.value) {
onFieldDelete(activeField.value)
}
})
onKeyDown('Backspace', () => {
if (document.activeElement?.tagName === 'INPUT') return
const isDeletedField = fieldStatus(activeField.value) === 'delete'
if (!isDeletedField && activeField.value) {
onFieldDelete(activeField.value)
}
})
onKeyDown('ArrowRight', () => {
if (document.activeElement?.tagName === 'INPUT') return
if (activeField.value) {
const input = document.querySelector('.nc-fields-input')
if (input) {
input.focus()
}
}
})
const onClickCopyFieldUrl = async (field: ColumnType) => {
await copy(field.id!)
isFieldIdCopied.value = true
}
const keys = useMagicKeys()
whenever(keys.meta_s, () => {
if (!meta.value?.id) return
columnsHash.value = (await $api.dbTableColumn.hash(meta.value?.id)).hash
if (openedViewsTab.value === 'field') saveChanges()
})
whenever(keys.ctrl_s, () => {
if (!meta.value?.id) return
if (openedViewsTab.value === 'field') saveChanges()
})
watch(
meta,
async (newMeta) => {
if (newMeta?.id) {
columnsHash.value = (await $api.dbTableColumn.hash(newMeta.id)).hash
}
},
{ deep: true },
)
onMounted(async () => {
if (meta.value && meta.value.id) {
columnsHash.value = (await $api.dbTableColumn.hash(meta.value.id)).hash
}
})
const onFieldOptionUpdate = () => {
setTimeout(() => {
isFieldIdCopied.value = false
}, 200)
}
</script>
<template>
@ -604,12 +718,15 @@ onMounted(async () => {
</template>
</a-input>
<div class="flex gap-2">
<NcButton type="secondary" size="small" class="mr-1" :disabled="loading" @click="addField()">
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" class="h-3.5 mb-1 w-3.5" />
New field
</div>
</NcButton>
<NcTooltip>
<template #title> {{ `${renderAltOrOptlKey()} + C` }} </template>
<NcButton type="secondary" size="small" class="mr-1" :disabled="loading" @click="addField()">
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" class="w-3" />
New Field
</div>
</NcButton>
</NcTooltip>
<NcButton
type="secondary"
size="small"
@ -618,15 +735,19 @@ onMounted(async () => {
>
Reset
</NcButton>
<NcButton
type="primary"
size="small"
:loading="loading"
:disabled="isColumnsValid ? !loading && ops.length < 1 && moveOps.length < 1 && visibilityOps.length < 1 : true"
@click="saveChanges()"
>
Save changes
</NcButton>
<NcTooltip>
<template #title> {{ `${renderCmdOrCtrlKey()} + S` }} </template>
<NcButton
type="primary"
size="small"
:loading="loading"
:disabled="isColumnsValid ? !loading && ops.length < 1 && moveOps.length < 1 && visibilityOps.length < 1 : true"
@click="saveChanges()"
>
Save changes
</NcButton>
</NcTooltip>
</div>
</div>
<div class="flex flex-row rounded-lg border-1 border-gray-200">
@ -647,14 +768,21 @@ onMounted(async () => {
visibilityOps.find((op) => op.column.fk_column_id === field.id)?.visible ?? viewFieldsMap[field.id].show
"
@change="
(event) => {
(event: any) => {
toggleVisibility(event.target.checked, viewFieldsMap[field.id])
}
"
/>
<NcCheckbox v-else :disabled="true" class="opacity-0" :checked="true" />
<SmartsheetHeaderVirtualCellIcon
v-if="field && isVirtualCol(fieldState(field) || field)"
:column-meta="fieldState(field) || field"
:class="{
'text-brand-500': compareCols(field, activeField),
}"
/>
<SmartsheetHeaderCellIcon
v-if="field"
v-else
:column-meta="fieldState(field) || field"
:class="{
'text-brand-500': compareCols(field, activeField),
@ -713,11 +841,50 @@ onMounted(async () => {
Restore
</div>
</NcButton>
<NcDropdown v-else :trigger="['click']" overlay-class-name="nc-dropdown-table-explorer" @click.stop>
<GeneralIcon icon="threeDotVertical" class="no-action opacity-0 group-hover:(opacity-100) text-gray-500" />
<NcDropdown
v-else
:trigger="['click']"
overlay-class-name="nc-dropdown-table-explorer"
@update:visible="onFieldOptionUpdate"
@click.stop
>
<NcButton
size="xsmall"
type="text"
class="!opacity-0 !group-hover:(opacity-100)"
:class="{
'!hover:(text-brand-700 bg-brand-100) !group-hover:(text-brand-500)': compareCols(field, activeField),
'!hover:(text-gray-700 bg-gray-200) !group-hover:(text-gray-500)': !compareCols(field, activeField),
}"
>
<GeneralIcon icon="threeDotVertical" class="no-action text-inherit" />
</NcButton>
<template #overlay>
<NcMenu>
<NcMenu style="padding-top: 0.45rem !important">
<template v-if="fieldStatus(field) !== 'add'">
<NcTooltip placement="top">
<template #title>{{ $t('msg.clickToCopyFieldId') }}</template>
<div
class="flex flex-row px-3 py-2 w-46 justify-between items-center group hover:bg-gray-100 cursor-pointer"
@click="onClickCopyFieldUrl(field)"
>
<div class="flex flex-row items-baseline gap-x-1 font-bold text-xs">
<div class="text-gray-600">{{ $t('labels.idColon') }}</div>
<div class="flex flex-row text-gray-600 text-xs">
{{ field.id }}
</div>
</div>
<NcButton size="xsmall" type="secondary" class="!group-hover:bg-gray-100">
<GeneralIcon v-if="isFieldIdCopied" icon="check" />
<GeneralIcon v-else icon="copy" />
</NcButton>
</div>
</NcTooltip>
<a-menu-divider class="my-1.5" />
</template>
<NcMenuItem key="table-explorer-duplicate" @click="duplicateField(field)">
<Icon class="iconify text-gray-800" icon="lucide:copy" /><span>Duplicate</span>
</NcMenuItem>
@ -728,11 +895,11 @@ onMounted(async () => {
<Icon class="iconify text-gray-800" icon="lucide:arrow-down" /><span>Insert below</span>
</NcMenuItem>
<a-menu-divider class="my-1" />
<a-menu-divider class="my-1.5" />
<NcMenuItem key="table-explorer-delete" @click="onFieldDelete(field)">
<NcMenuItem key="table-explorer-delete" class="!hover:bg-red-50" @click="onFieldDelete(field)">
<div class="text-red-500">
<GeneralIcon icon="delete" class="group-hover:text-accent" />
<GeneralIcon icon="delete" class="group-hover:text-accent -ml-0.25 -mt-0.75 mr-0.5" />
Delete
</div>
</NcMenuItem>
@ -748,7 +915,12 @@ onMounted(async () => {
</div>
</div>
</template>
<template v-if="displayColumn && displayColumn.title.toLowerCase().includes(searchQuery.toLowerCase())" #header>
<template
v-if="
displayColumn && displayColumn.title && displayColumn.title.toLowerCase().includes(searchQuery.toLowerCase())
"
#header
>
<div
class="flex px-2 bg-white hover:bg-gray-100 border-b-1 border-gray-200 first:rounded-tl-lg last:border-b-1 pl-5 group"
:class="` ${compareCols(displayColumn, activeField) ? 'selected' : ''}`"
@ -805,6 +977,55 @@ onMounted(async () => {
Restore
</div>
</NcButton>
<NcDropdown
v-else
:trigger="['click']"
overlay-class-name="nc-dropdown-table-explorer-display-column"
@update:visible="onFieldOptionUpdate"
@click.stop
>
<NcButton
size="xsmall"
type="text"
class="!opacity-0 !group-hover:(opacity-100)"
:class="{
'!hover:(text-brand-700 bg-brand-100) !group-hover:(text-brand-500)': compareCols(
displayColumn,
activeField,
),
'!hover:(text-gray-700 bg-gray-200) !group-hover:(text-gray-500)': !compareCols(
displayColumn,
activeField,
),
}"
>
<GeneralIcon icon="threeDotVertical" class="no-action text-inherit" />
</NcButton>
<template #overlay>
<NcMenu>
<NcTooltip placement="top">
<template #title>{{ $t('msg.clickToCopyFieldId') }}</template>
<div
class="flex flex-row px-3 py-2 w-46 justify-between items-center group hover:bg-gray-100 cursor-pointer"
@click="onClickCopyFieldUrl(displayColumn)"
>
<div class="flex flex-row items-baseline gap-x-1 font-bold text-xs">
<div class="text-gray-600">{{ $t('labels.idColon') }}</div>
<div class="flex flex-row text-gray-600 text-xs">
{{ displayColumn.id }}
</div>
</div>
<NcButton size="xsmall" type="secondary" class="!group-hover:bg-gray-100">
<GeneralIcon v-if="isFieldIdCopied" icon="check" />
<GeneralIcon v-else icon="copy" />
</NcButton>
</div>
</NcTooltip>
</NcMenu>
</template>
</NcDropdown>
<MdiChevronRight
class="text-brand-500 opacity-0"
:class="{
@ -844,7 +1065,26 @@ onMounted(async () => {
</div>
</template>
<style lang="scss">
.nc-dropdown-table-explorer {
@apply !overflow-hidden;
}
.nc-dropdown-table-explorer > div > ul.ant-dropdown-menu.nc-menu {
@apply !pt-0;
}
.nc-dropdown-table-explorer-display-column {
@apply !overflow-hidden;
}
.nc-dropdown-table-explorer-display-column > div > ul.ant-dropdown-menu.nc-menu {
@apply !py-1.5;
}
</style>
<style lang="scss" scoped>
:deep(ul.ant-dropdown-menu.nc-menu) {
@apply !pt-0;
}
.add {
background-color: #e6ffed !important;
border-color: #b7eb8f;

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

@ -10,6 +10,8 @@ const props = defineProps<{
const { loadCommentsAndLogs, commentsAndLogs, saveComment: _saveComment, comment, updateComment } = useExpandedFormStoreOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const commentsWrapperEl = ref<HTMLDivElement>()
const { user, appInfo } = useGlobal()
@ -26,6 +28,8 @@ const editLog = ref<AuditType>()
const isEditing = ref<boolean>(false)
const commentInputDomRef = ref<HTMLInputElement>()
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
function onKeyDown(event: KeyboardEvent) {
@ -123,6 +127,15 @@ const onClickAudit = () => {
tab.value = 'audits'
}
watch(commentInputDomRef, () => {
if (commentInputDomRef.value && isExpandedFormCommentMode.value) {
setTimeout(() => {
commentInputDomRef.value?.focus()
isExpandedFormCommentMode.value = false
}, 400)
}
})
</script>
<template>
@ -238,11 +251,13 @@ const onClickAudit = () => {
</div>
<div v-if="hasEditPermission" class="p-2 bg-gray-50 gap-2 flex">
<div class="h-14 flex flex-row w-full bg-white py-2.75 px-1.5 items-center rounded-xl border-1 border-gray-200">
<GeneralUserIcon size="base" class="!w-10" :email="user?.email" />
<GeneralUserIcon size="base" class="!w-10" :email="user?.email" :name="user?.display_name" />
<a-input
ref="commentInputDomRef"
v-model:value="comment"
class="!rounded-lg border-1 bg-white !px-2.5 !py-2 !border-gray-200 nc-comment-box !outline-none"
placeholder="Start typing..."
data-testid="expanded-form-comment-input"
:bordered="false"
@keyup.enter.prevent="saveComment"
>

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { TableType, ViewType } from 'nocodb-sdk'
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import { ViewTypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import MdiChevronDown from '~icons/mdi/chevron-down'
@ -41,11 +41,13 @@ interface Props {
showNextPrevIcons?: boolean
firstRow?: boolean
lastRow?: boolean
closeAfterSave?: boolean
newRecordHeader?: string
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev'])
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev', 'createdRecord'])
const { activeView } = storeToRefs(useViewsStore())
@ -90,6 +92,8 @@ const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, undefined)
@ -127,7 +131,6 @@ const {
primaryKey,
saveRowAndStay,
row: _row,
syncLTARRefs,
save: _save,
loadCommentsAndLogs,
clearColumns,
@ -185,7 +188,6 @@ const onDuplicateRow = () => {
const save = async () => {
if (isNew.value) {
const data = await _save(rowState.value)
await syncLTARRefs(data)
reloadTrigger?.trigger()
} else {
let kanbanClbk
@ -201,6 +203,12 @@ const save = async () => {
reloadTrigger?.trigger()
}
isUnsavedFormExist.value = false
if (props.closeAfterSave) {
isExpanded.value = false
}
emits('createdRecord', _row.value.row)
}
const isPreventChangeModalOpen = ref(false)
@ -283,6 +291,9 @@ const cellWrapperEl = ref()
onMounted(async () => {
isRecordLinkCopied.value = false
isLoading.value = true
const focusFirstCell = !isExpandedFormCommentMode.value
if (props.loadRow) {
await _loadRow()
await loadCommentsAndLogs()
@ -294,8 +305,7 @@ onMounted(async () => {
await loadCommentsAndLogs()
} catch (e: any) {
if (e.response?.status === 404) {
// todo: i18n
message.error('Record not found')
message.error(t('msg.noRecordFound'))
router.replace({ query: {} })
} else throw e
}
@ -303,9 +313,11 @@ onMounted(async () => {
isLoading.value = false
setTimeout(() => {
cellWrapperEl.value?.$el?.querySelector('input,select,textarea')?.focus()
}, 300)
if (focusFirstCell) {
setTimeout(() => {
cellWrapperEl.value?.$el?.querySelector('input,select,textarea')?.focus()
}, 300)
}
})
const addNewRow = () => {
@ -341,8 +353,7 @@ useActiveKeyupListener(
e.stopPropagation()
if (isNew.value) {
const data = await _save(rowState.value)
await syncLTARRefs(data)
await _save(rowState.value)
reloadHook?.trigger(null)
} else {
await save()
@ -358,9 +369,9 @@ useActiveKeyupListener(
if (changedColumns.value.size > 0) {
await Modal.confirm({
title: 'Do you want to save the changes?',
okText: 'Save',
cancelText: 'Discard',
title: t('msg.saveChanges'),
okText: t('general.save'),
cancelText: t('labels.discard'),
onOk: async () => {
await save()
reloadHook?.trigger(null)
@ -373,11 +384,10 @@ useActiveKeyupListener(
} else if (isNew.value) {
await Modal.confirm({
title: 'Do you want to save the record?',
okText: 'Save',
cancelText: 'Discard',
okText: t('general.save'),
cancelText: t('labels.discard'),
onOk: async () => {
const data = await _save(rowState.value)
await syncLTARRefs(data)
await _save(rowState.value)
reloadHook?.trigger(null)
addNewRow()
},
@ -402,7 +412,7 @@ const onDeleteRowClick = () => {
const onConfirmDeleteRowClick = async () => {
showDeleteRowModal.value = false
await deleteRowById(primaryKey.value)
message.success('Record deleted')
message.success(t('msg.rowDeleted'))
reloadTrigger.trigger()
onClose()
showDeleteRowModal.value = false
@ -417,7 +427,26 @@ const showRightSections = computed(() => {
return !isNew.value && commentsDrawer.value && isUIAllowed('commentList')
})
const preventModalStatus = computed(() => isCloseModalOpen.value || isPreventChangeModalOpen.value)
const preventModalStatus = computed({
get: () => isCloseModalOpen.value || isPreventChangeModalOpen.value,
set: (v) => {
isCloseModalOpen.value = v
},
})
const onIsExpandedUpdate = (v: boolean) => {
if (changedColumns.value.size === 0 && !isUnsavedFormExist.value) {
isExpanded.value = v
} else if (!v) {
preventModalStatus.value = true
} else {
isExpanded.value = v
}
}
const isReadOnlyVirtualCell = (column: ColumnType) => {
return isRollup(column) || isFormula(column) || isBarcode(column) || isLookup(column) || isQrCode(column)
}
</script>
<script lang="ts">
@ -428,7 +457,7 @@ export default {
<template>
<NcModal
v-model:visible="isExpanded"
:visible="isExpanded"
:footer="null"
:width="commentsDrawer && isUIAllowed('commentList') ? 'min(80vw,1280px)' : 'min(80vw,1280px)'"
:body-style="{ padding: 0 }"
@ -436,6 +465,7 @@ export default {
size="small"
class="nc-drawer-expanded-form"
:class="{ active: isExpanded }"
@update:visible="onIsExpandedUpdate"
>
<div class="h-[85vh] xs:(max-h-full) max-h-215 flex flex-col p-6">
<div class="flex h-9.5 flex-shrink-0 w-full items-center nc-expanded-form-header relative mb-4 justify-between">
@ -464,12 +494,17 @@ export default {
<div v-if="isLoading">
<a-skeleton-input class="!h-8 !sm:mr-14 !w-52 mt-1 !rounded-md !overflow-hidden" active size="small" />
</div>
<div
v-if="row.rowMeta?.new || props.newRecordHeader"
class="flex items-center truncate font-bold text-gray-800 text-xl"
>
{{ props.newRecordHeader ?? $t('activity.newRecord') }}
</div>
<div v-else-if="displayValue && !row.rowMeta?.new" class="flex items-center font-bold text-gray-800 text-xl w-64">
<span class="truncate">
{{ displayValue }}
</span>
</div>
<div v-if="row.rowMeta?.new" class="flex items-center truncate font-bold text-gray-800 text-xl">New Record</div>
</div>
<div class="flex gap-2">
<NcButton
@ -618,9 +653,20 @@ export default {
<SmartsheetDivDataCell
v-if="col.title"
:ref="i ? null : (el: any) => (cellWrapperEl = el)"
class="!bg-white rounded-lg !w-[20rem] !xs:w-full border-1 border-gray-200 overflow-hidden px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative"
class="bg-white rounded-lg !w-[20rem] !xs:w-full border-1 border-gray-200 overflow-hidden px-1 sm:min-h-[35px] xs:min-h-13 flex items-center relative"
:class="{
'!bg-gray-50 !px-0 !select-text': isReadOnlyVirtualCell(col),
}"
>
<LazySmartsheetVirtualCell v-if="isVirtualCol(col)" v-model="_row.row[col.title]" :row="_row" :column="col" />
<LazySmartsheetVirtualCell
v-if="isVirtualCol(col)"
v-model="_row.row[col.title]"
:row="_row"
:column="col"
:class="{
'px-1': isReadOnlyVirtualCell(col),
}"
/>
<LazySmartsheetCell
v-else

39
packages/nc-gui/components/smartsheet/grid/GroupBy.vue

@ -1,8 +1,10 @@
<script lang="ts" setup>
import tinycolor from 'tinycolor2'
import { UITypes } from 'nocodb-sdk'
import Table from './Table.vue'
import GroupBy from './GroupBy.vue'
import GroupByTable from './GroupByTable.vue'
import GroupByLabel from './GroupByLabel.vue'
import { GROUP_BY_VARS, computed, ref } from '#imports'
import type { Group, Row } from '#imports'
@ -134,6 +136,27 @@ const onScroll = (e: Event) => {
if (!vGroup.value.root) return
_scrollLeft.value = (e.target as HTMLElement).scrollLeft
}
// a method to parse group key if grouped column type is LTAR or Lookup
// in these 2 scenario it will return json array or `___` separated value
const parseKey = (group) => {
const key = group.key.toString()
// parse json array key if it's a lookup or link to another record
if ((key && group.column?.uidt === UITypes.Lookup) || group.column?.uidt === UITypes.LinkToAnotherRecord) {
try {
const parsedKey = JSON.parse(key)
return parsedKey
} catch {
// if parsing try to split it by `___` (for sqlite)
return key.split('___')
}
}
return [key]
}
const shouldRenderCell = (column) =>
[UITypes.Lookup, UITypes.Attachment, UITypes.Barcode, UITypes.QrCode, UITypes.Links].includes(column?.uidt)
</script>
<template>
@ -227,6 +250,15 @@ const onScroll = (e: Event) => {
</span>
</a-tag>
</template>
<div
v-else-if="!(grp.key in GROUP_BY_VARS.VAR_TITLES) && shouldRenderCell(grp.column)"
class="flex min-w-[100px] flex-wrap"
>
<template v-for="(val, ind) of parseKey(grp)" :key="ind">
<GroupByLabel v-if="val" :column="grp.column" :model-value="val" />
<span v-else class="text-gray-400">No mapped value</span>
</template>
</div>
<a-tag
v-else
:key="`panel-tag-${grp.column.id}-${grp.key}`"
@ -247,7 +279,12 @@ const onScroll = (e: Event) => {
'font-weight': 500,
}"
>
{{ grp.key in GROUP_BY_VARS.VAR_TITLES ? GROUP_BY_VARS.VAR_TITLES[grp.key] : grp.key }}
<template v-if="grp.key in GROUP_BY_VARS.VAR_TITLES">{{
GROUP_BY_VARS.VAR_TITLES[grp.key]
}}</template>
<template v-else>
{{ parseKey(grp)?.join(', ') }}
</template>
</span>
</a-tag>
</div>

28
packages/nc-gui/components/smartsheet/grid/GroupByLabel.vue

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { isVirtualCol } from 'nocodb-sdk'
defineProps<{
column: ColumnType
modelValue: any
}>()
provide(ReadonlyInj, true)
</script>
<template>
<div class="pointer-events-none">
<LazySmartsheetRow :row="{ row: { [column.title]: modelValue }, rowMeta: {} }">
<LazySmartsheetVirtualCell v-if="isVirtualCol(column)" :model-value="modelValue" class="!text-gray-600" :column="column" />
<LazySmartsheetCell
v-else
:model-value="modelValue"
class="!text-gray-600"
:column="column"
:edit-enabled="false"
:read-only="true"
/>
</LazySmartsheetRow>
</div>
</template>

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

@ -3,6 +3,9 @@ import axios from 'axios'
import { nextTick } from '@vue/runtime-core'
import type { ColumnReqType, ColumnType, PaginatedType, TableType, ViewType } from 'nocodb-sdk'
import { UITypes, ViewTypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { useColumnDrag } from './useColumnDrag'
import usePaginationShortcuts from './usePaginationShortcuts'
import {
ActiveViewInj,
CellUrlDisableOverlayInj,
@ -141,6 +144,8 @@ const { addUndo, clone, defineViewScope } = useUndoRedo()
const { isViewColumnsLoading, updateGridViewColumn, gridViewCols, resizingColOldWith } = useViewColumnsOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const {
predictingNextColumn,
predictedNextColumn,
@ -176,9 +181,20 @@ const gridRect = useElementBounding(gridWrapper)
// #Permissions
const { isUIAllowed } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('dataEdit'))
const hasEditPermission = computed(() => isUIAllowed('dataEdit') && !isLocked.value)
const isAddingColumnAllowed = computed(() => !readOnly.value && !isLocked.value && isUIAllowed('fieldAdd') && !isSqlView.value)
const { onDrag, onDragStart, draggedCol, dragColPlaceholderDomRef, toBeDroppedColId } = useColumnDrag({
fields,
tableBodyEl,
gridWrapper,
})
const { onLeft, onRight, onUp, onDown } = usePaginationShortcuts({
paginationDataRef,
changePage: changePage as any,
})
// #Variables
const addColumnDropdown = ref(false)
@ -207,9 +223,7 @@ const _contextMenu = ref(false)
const contextMenu = computed({
get: () => _contextMenu.value,
set: (val) => {
if (hasEditPermission.value) {
_contextMenu.value = val
}
_contextMenu.value = val
},
})
const contextMenuClosing = ref(false)
@ -371,7 +385,7 @@ const gridWrapperClass = computed<string>(() => {
const classes = []
if (headerOnly !== true) {
if (!scrollParent.value) {
classes.push('nc-scrollbar-x-md overflow-auto')
classes.push('nc-scrollbar-x-lg !overflow-auto')
}
} else {
classes.push('overflow-visible')
@ -696,6 +710,23 @@ const confirmDeleteRow = (row: number) => {
}
}
const commentRow = (rowId: number) => {
try {
isExpandedFormCommentMode.value = true
const row = dataRef.value[rowId]
if (expandForm) {
expandForm(row)
}
activeCell.row = null
activeCell.col = null
selectedRange.clear()
} catch (e: any) {
message.error(e.message)
}
}
const deleteSelectedRangeOfRows = () => {
deleteRangeOfRows?.(selectedRange).then(() => {
clearSelectedRange()
@ -704,6 +735,15 @@ const deleteSelectedRangeOfRows = () => {
})
}
const selectColumn = (columnId: string) => {
const colIndex = fields.value.findIndex((col) => col.id === columnId)
if (colIndex !== -1) {
makeActive(0, colIndex)
selectedRange.startRange({ row: 0, col: colIndex })
selectedRange.endRange({ row: dataRef.value.length - 1, col: colIndex })
}
}
/** On clicking outside of table reset active cell */
onClickOutside(tableBodyEl, (e) => {
// do nothing if mousedown on the scrollbar (scrolling)
@ -1187,10 +1227,34 @@ const loaderText = computed(() => {
}
}
})
// Keyboard shortcuts for pagination
onKeyStroke('ArrowLeft', onLeft)
onKeyStroke('ArrowRight', onRight)
onKeyStroke('ArrowUp', onUp)
onKeyStroke('ArrowDown', onDown)
</script>
<template>
<div class="flex flex-col" :class="`${headerOnly !== true ? 'h-full w-full' : ''}`">
<div data-testid="drag-icon-placeholder" class="absolute w-1 h-1 pointer-events-none opacity-0"></div>
<div
ref="dragColPlaceholderDomRef"
:class="{
'hidden w-0 !h-0 left-0 !max-h-0 !max-w-0': !draggedCol,
}"
class="absolute flex items-center z-40 top-0 h-full bg-gray-50 pointer-events-none opacity-60"
>
<div
v-if="draggedCol"
:style="{
'min-width': gridViewCols[draggedCol.id!]?.width || '200px',
'max-width': gridViewCols[draggedCol.id!]?.width || '200px',
'width': gridViewCols[draggedCol.id!]?.width || '200px',
}"
class="border-r-1 border-l-1 border-gray-200 h-full"
></div>
</div>
<div ref="gridWrapper" class="nc-grid-wrapper min-h-0 flex-1 relative" :class="gridWrapperClass">
<div
v-show="showSkeleton && !isPaginationLoading && showLoaderAfterDelay"
@ -1206,13 +1270,14 @@ const loaderText = computed(() => {
:trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu"
>
<div class="table-overlay" :class="{ 'nc-grid-skelton-loader': showSkeleton }">
<div class="table-overlay" :class="{ 'nc-grid-skeleton-loader': showSkeleton }">
<table
ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative"
:class="{
mobile: isMobileMode,
desktop: !isMobileMode,
'mobile': isMobileMode,
'desktop': !isMobileMode,
'pr-60 pb-12': !headerOnly,
}"
@contextmenu="showContextMenu"
>
@ -1253,7 +1318,7 @@ const loaderText = computed(() => {
</div>
</th>
<th
v-for="col in fields"
v-for="(col, index) in fields"
:key="col.title"
v-xc-ver-resize
:data-col="col.id"
@ -1263,11 +1328,21 @@ const loaderText = computed(() => {
'max-width': gridViewCols[col.id]?.width || '200px',
'width': gridViewCols[col.id]?.width || '200px',
}"
class="nc-grid-column-header"
:class="{
'!border-r-blue-400 !border-r-3': toBeDroppedColId === col.id,
}"
@xcstartresizing="onXcStartResizing(col.id, $event)"
@xcresize="onresize(col.id, $event)"
@xcresizing="onXcResizing(col.id, $event)"
@click="selectColumn(col.id!)"
>
<div class="w-full h-full flex items-center">
<div
class="w-full h-full flex items-center"
:draggable="isMobileMode || index === 0 || readOnly || !hasEditPermission ? 'false' : 'true'"
@dragstart.stop="onDragStart(col.id!, $event)"
@drag.stop="onDrag($event)"
>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(col)"
:column="col"
@ -1472,14 +1547,12 @@ const loaderText = computed(() => {
<SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields"
:key="columnObj.id"
class="cell relative nc-grid-cell"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'cursor-pointer': hasEditPermission,
'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'active': isCellSelected(rowIndex, colIndex),
'active-cell':
hasEditPermission &&
((activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex)),
(activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex),
'last-cell':
rowIndex === (isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) &&
colIndex === (isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col),
@ -1491,6 +1564,7 @@ const loaderText = computed(() => {
(isLookup(columnObj) || isRollup(columnObj) || isFormula(columnObj)) &&
hasEditPermission &&
isCellSelected(rowIndex, colIndex),
'!border-r-blue-400 !border-r-3': toBeDroppedColId === columnObj.id,
}"
:style="{
'min-width': gridViewCols[columnObj.id]?.width || '200px',
@ -1516,7 +1590,7 @@ const loaderText = computed(() => {
:column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row"
:read-only="readOnly"
:read-only="!hasEditPermission"
@navigate="onNavigate"
@save="updateOrSaveRow?.(row, '', state)"
/>
@ -1530,7 +1604,7 @@ const loaderText = computed(() => {
"
:row-index="rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:read-only="readOnly"
:read-only="!hasEditPermission"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow?.(row, columnObj.title, state)"
@navigate="onNavigate"
@ -1580,7 +1654,7 @@ const loaderText = computed(() => {
/>
</div>
<template v-if="!isLocked && hasEditPermission" #overlay>
<template #overlay>
<NcMenu class="!rounded !py-0" @click="contextMenu = false">
<NcMenuItem
v-if="isEeUI && !contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)"
@ -1631,6 +1705,7 @@ const loaderText = computed(() => {
<NcMenuItem
v-if="
contextMenuTarget &&
hasEditPermission &&
selectedRange.isSingleCell() &&
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col]))
"
@ -1644,7 +1719,7 @@ const loaderText = computed(() => {
<!-- Clear cell -->
<NcMenuItem
v-else-if="contextMenuTarget"
v-else-if="contextMenuTarget && hasEditPermission"
v-e="['a:row:clear-range']"
class="nc-base-menu-item"
@click="clearSelectedRangeOfCells()"
@ -1653,28 +1728,40 @@ const loaderText = computed(() => {
{{ $t('general.clear') }}
</NcMenuItem>
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />
<NcMenuItem
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="confirmDeleteRow(contextMenuTarget.row)"
<template
v-if="contextMenuTarget && !isLocked && selectedRange.isSingleCell() && isUIAllowed('commentEdit') && !isMobileMode"
>
<GeneralIcon icon="delete" />
<!-- Delete Row -->
{{ $t('activity.deleteRow') }}
</NcMenuItem>
<div v-else-if="contextMenuTarget && deleteRangeOfRows">
<NcDivider />
<NcMenuItem v-e="['a:row:comment']" class="nc-base-menu-item" @click="commentRow(contextMenuTarget.row)">
<MdiMessageOutline class="h-4 w-4" />
{{ $t('general.comment') }}
</NcMenuItem>
</template>
<template v-if="hasEditPermission">
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />
<NcMenuItem
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="deleteSelectedRangeOfRows"
@click="confirmDeleteRow(contextMenuTarget.row)"
>
<GeneralIcon icon="delete" class="text-gray-500 text-red-600" />
<!-- Delete Rows -->
{{ $t('activity.deleteRows') }}
<GeneralIcon icon="delete" />
<!-- Delete Row -->
{{ $t('activity.deleteRow') }}
</NcMenuItem>
</div>
<div v-else-if="contextMenuTarget && deleteRangeOfRows">
<NcMenuItem
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="deleteSelectedRangeOfRows"
>
<GeneralIcon icon="delete" class="text-gray-500 text-red-600" />
<!-- Delete Rows -->
{{ $t('activity.deleteRows') }}
</NcMenuItem>
</div>
</template>
</NcMenu>
</template>
</NcDropdown>
@ -1913,7 +2000,7 @@ const loaderText = computed(() => {
}
}
.nc-grid-skelton-loader {
.nc-grid-skeleton-loader {
thead th:nth-child(2) {
@apply border-r-1 !border-r-gray-50;
}

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

@ -0,0 +1,150 @@
import type { ColumnType } from 'nocodb-sdk'
export const useColumnDrag = ({
fields,
tableBodyEl,
gridWrapper,
}: {
fields: Ref<ColumnType[]>
tableBodyEl: Ref<HTMLElement | undefined>
gridWrapper: Ref<HTMLElement | undefined>
}) => {
const { eventBus } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo()
const { activeView } = storeToRefs(useViewsStore())
const { gridViewCols, updateGridViewColumn } = useViewColumnsOrThrow()
const { leftSidebarWidth } = storeToRefs(useSidebarStore())
const { width } = useWindowSize()
const draggedCol = ref<ColumnType | null>(null)
const dragColPlaceholderDomRef = ref<HTMLElement | null>(null)
const toBeDroppedColId = ref<string | null>(null)
const reorderColumn = async (colId: string, toColId: string) => {
const toBeReorderedViewCol = gridViewCols.value[colId]
const toViewCol = gridViewCols.value[toColId]!
const toColIndex = fields.value.findIndex((f) => f.id === toColId)
const nextToColField = toColIndex < fields.value.length - 1 ? fields.value[toColIndex + 1] : null
const nextToViewCol = nextToColField ? gridViewCols.value[nextToColField.id!] : null
const lastCol = fields.value[fields.value.length - 1]
const lastViewCol = gridViewCols.value[lastCol.id!]
const newOrder = nextToViewCol ? toViewCol.order! + (nextToViewCol.order! - toViewCol.order!) / 2 : lastViewCol.order! + 1
const oldOrder = toBeReorderedViewCol.order
toBeReorderedViewCol.order = newOrder
addUndo({
undo: {
fn: async () => {
if (!fields.value) return
toBeReorderedViewCol.order = oldOrder
await updateGridViewColumn(colId, { order: oldOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
},
args: [],
},
redo: {
fn: async () => {
if (!fields.value) return
toBeReorderedViewCol.order = newOrder
await updateGridViewColumn(colId, { order: newOrder } as any)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
},
args: [],
},
scope: defineViewScope({ view: activeView.value }),
})
await updateGridViewColumn(colId, { order: newOrder } as any, true)
eventBus.emit(SmartsheetStoreEvents.FIELD_RELOAD)
}
const onDragStart = (colId: string, e: DragEvent) => {
if (!e.dataTransfer) return
const dom = document.querySelector('[data-testid="drag-icon-placeholder"]')
e.dataTransfer.dropEffect = 'none'
e.dataTransfer.effectAllowed = 'none'
e.dataTransfer.setDragImage(dom!, 10, 10)
e.dataTransfer.clearData()
e.dataTransfer.setData('text/plain', colId)
draggedCol.value = fields.value.find((f) => f.id === colId) ?? null
const remInPx = parseFloat(getComputedStyle(document.documentElement).fontSize)
const placeholderHeight = tableBodyEl.value?.getBoundingClientRect().height ?? 6.1 * remInPx
dragColPlaceholderDomRef.value!.style.height = `${placeholderHeight}px`
const x = e.clientX - leftSidebarWidth.value
if (x >= 0 && dragColPlaceholderDomRef.value) {
dragColPlaceholderDomRef.value.style.left = `${x.toString()}px`
}
}
const onDrag = (e: DragEvent) => {
e.preventDefault()
if (!e.dataTransfer) return
if (!draggedCol.value) return
if (!dragColPlaceholderDomRef.value) return
if (e.clientX === 0) {
dragColPlaceholderDomRef.value!.style.left = `0px`
dragColPlaceholderDomRef.value!.style.height = '0px'
reorderColumn(draggedCol.value!.id!, toBeDroppedColId.value!)
draggedCol.value = null
toBeDroppedColId.value = null
return
}
const y = dragColPlaceholderDomRef.value!.getBoundingClientRect().top
const domsUnderMouse = document.elementsFromPoint(e.clientX, y)
const columnDom = domsUnderMouse.find((dom) => dom.classList.contains('nc-grid-column-header'))
if (columnDom) {
toBeDroppedColId.value = columnDom?.getAttribute('data-col') ?? null
}
const x = e.clientX - leftSidebarWidth.value
if (x >= 0) {
dragColPlaceholderDomRef.value.style.left = `${x.toString()}px`
}
const remInPx = parseFloat(getComputedStyle(document.documentElement).fontSize)
if (x < leftSidebarWidth.value + 1 * remInPx) {
setTimeout(() => {
gridWrapper.value!.scrollLeft -= 2.5
}, 250)
} else if (width.value - x - leftSidebarWidth.value < 15 * remInPx) {
setTimeout(() => {
gridWrapper.value!.scrollLeft += 2.5
}, 250)
}
}
return {
onDrag,
onDragStart,
draggedCol,
dragColPlaceholderDomRef,
toBeDroppedColId,
}
}

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

Loading…
Cancel
Save