Browse Source

Merge pull request #9047 from nocodb/develop

pull/9049/head 0.251.2
github-actions[bot] 4 months ago committed by GitHub
parent
commit
48b5f7868f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 23
      .github/ISSUE_TEMPLATE/--bug-report.yaml
  2. 18
      .github/ISSUE_TEMPLATE/--feature-request.yaml
  3. 4
      .github/workflows/ci-cd.yml
  4. 2
      .github/workflows/playwright-test-workflow.yml
  5. 2
      .github/workflows/pre-build-for-playwright.yml
  6. 2
      .github/workflows/release-docker.yml
  7. 2
      .github/workflows/release-npm.yml
  8. 2
      .github/workflows/sync-to-develop.yml
  9. 2
      .github/workflows/unit-test.yml
  10. 2
      .github/workflows/update-sdk-path.yml
  11. 2
      README.md
  12. 25
      docker-compose/setup-script/noco.sh
  13. 2
      package.json
  14. 6
      packages/nc-gui/assets/nc-icons/camera.svg
  15. 10
      packages/nc-gui/assets/nc-icons/file.svg
  16. 2
      packages/nc-gui/components/api-client/Headers.vue
  17. 11
      packages/nc-gui/components/api-client/Params.vue
  18. 37
      packages/nc-gui/components/cell/Json.vue
  19. 135
      packages/nc-gui/components/cell/attachment/AttachFile.vue
  20. 123
      packages/nc-gui/components/cell/attachment/Modal.vue
  21. 147
      packages/nc-gui/components/cell/attachment/UploadProviders/Camera.vue
  22. 190
      packages/nc-gui/components/cell/attachment/UploadProviders/Local.vue
  23. 173
      packages/nc-gui/components/cell/attachment/UploadProviders/Url.vue
  24. 35
      packages/nc-gui/components/cell/attachment/index.vue
  25. 56
      packages/nc-gui/components/cell/attachment/utils.ts
  26. 1
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  27. 6
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  28. 70
      packages/nc-gui/components/dashboard/View.vue
  29. 8
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  30. 238
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  31. 5
      packages/nc-gui/components/dlg/AirtableImport.vue
  32. 60
      packages/nc-gui/components/dlg/QuickImport.vue
  33. 88
      packages/nc-gui/components/extensions/Details.vue
  34. 227
      packages/nc-gui/components/extensions/Extension.vue
  35. 49
      packages/nc-gui/components/extensions/ExtensionMenu.vue
  36. 78
      packages/nc-gui/components/extensions/Market.vue
  37. 137
      packages/nc-gui/components/extensions/Pane.vue
  38. 8
      packages/nc-gui/components/general/DeleteModal.vue
  39. 5
      packages/nc-gui/components/general/MaintenanceAlert.vue
  40. 4
      packages/nc-gui/components/general/ViewIcon.vue
  41. 243
      packages/nc-gui/components/nc/PaginationV2.vue
  42. 46
      packages/nc-gui/components/nc/Select.vue
  43. 6
      packages/nc-gui/components/smartsheet/Cell.vue
  44. 8
      packages/nc-gui/components/smartsheet/Kanban.vue
  45. 6
      packages/nc-gui/components/smartsheet/PlainCell.vue
  46. 13
      packages/nc-gui/components/smartsheet/Toolbar.vue
  47. 15
      packages/nc-gui/components/smartsheet/Topbar.vue
  48. 3
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  49. 2
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  50. 9
      packages/nc-gui/components/smartsheet/column/LinkAdvancedOptions.vue
  51. 261
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  52. 4
      packages/nc-gui/components/smartsheet/column/LookupOptions.vue
  53. 9
      packages/nc-gui/components/smartsheet/details/Fields.vue
  54. 25
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  55. 24
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  56. 2
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  57. 4
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  58. 22
      packages/nc-gui/components/smartsheet/grid/Table.vue
  59. 4
      packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts
  60. 28
      packages/nc-gui/components/smartsheet/header/DeleteColumnModal.vue
  61. 25
      packages/nc-gui/components/smartsheet/header/Menu.vue
  62. 7
      packages/nc-gui/components/smartsheet/toolbar/Calendar/Range.vue
  63. 9
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  64. 2
      packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue
  65. 2
      packages/nc-gui/components/smartsheet/toolbar/CreateSort.vue
  66. 38
      packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue
  67. 36
      packages/nc-gui/components/smartsheet/toolbar/FieldListWithSearch.vue
  68. 17
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  69. 6
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  70. 11
      packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue
  71. 4
      packages/nc-gui/components/smartsheet/toolbar/RowHeight.vue
  72. 6
      packages/nc-gui/components/smartsheet/toolbar/SearchData.vue
  73. 9
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  74. 7
      packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue
  75. 22
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  76. 24
      packages/nc-gui/components/tabs/Smartsheet.vue
  77. 264
      packages/nc-gui/components/template/Editor.vue
  78. 11
      packages/nc-gui/components/template/utils.ts
  79. 4
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  80. 2
      packages/nc-gui/components/virtual-cell/HasMany.vue
  81. 2
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  82. 4
      packages/nc-gui/components/virtual-cell/OneToOne.vue
  83. 7
      packages/nc-gui/components/virtual-cell/Rollup.vue
  84. 49
      packages/nc-gui/components/virtual-cell/components/ItemChip.vue
  85. 16
      packages/nc-gui/components/workspace/AuditLogs.vue
  86. 11
      packages/nc-gui/composables/useColumnCreateStore.ts
  87. 8
      packages/nc-gui/composables/useExpandedFormStore.ts
  88. 44
      packages/nc-gui/composables/useExtensions.ts
  89. 13
      packages/nc-gui/composables/useGlobal/actions.ts
  90. 6
      packages/nc-gui/composables/useGlobal/state.ts
  91. 7
      packages/nc-gui/composables/useGlobal/types.ts
  92. 1
      packages/nc-gui/composables/useJobs.ts
  93. 2
      packages/nc-gui/composables/useMultiSelect/index.ts
  94. 2
      packages/nc-gui/composables/useRoles/index.ts
  95. 15
      packages/nc-gui/composables/useServerConfig.ts
  96. 4
      packages/nc-gui/composables/useSharedFormViewStore.ts
  97. 2
      packages/nc-gui/composables/useSharedView.ts
  98. 12
      packages/nc-gui/composables/useViewAggregate.ts
  99. 86
      packages/nc-gui/composables/useViewColumns.ts
  100. 67
      packages/nc-gui/composables/useViewGroupBy.ts
  101. Some files were not shown because too many files have changed in this diff Show More

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

@ -1,25 +1,25 @@
name: 🐛 Bug Report
description: Create a bug report to help improve NocoDB
description: Report a bug in NocoDB
title: "🐛 Bug: "
labels: [Type : Bug]
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this feature request report!
Thank you for taking the time to fill out this bug report! ❤
- type: checkboxes
attributes:
label: Please confirm if bug report does NOT exist already ?
description: We kindly ask that you [search](https://github.com/nocodb/nocodb/issues?q=is%3Aissue+sort%3Acreated-desc+) to see if an issue already exists for your bug
label: Please confirm that the bug report does ***not*** already exist
description: We kindly ask you [to search the open issues](https://github.com/nocodb/nocodb/issues?q=is%3Aissue+sort%3Acreated-desc+) and ensure the bug has not already been reported before.
options:
- label: I confirm there is no existing issue for this
- label: I confirm there is no existing issue for this bug.
required: true
- type: textarea
attributes:
label: Steps to reproduce ?
description: A clear and concise steps on how to reproduce the issue. More details the better.
label: Steps to reproduce
description: A clear and concise example on how to reproduce the issue. Please make sure to provide all relevant technical details.
validations:
required: true
@ -33,9 +33,10 @@ body:
- type: textarea
attributes:
label: Project Details
description: Click on top left icon and click `Copy Project Info`. (See [YouTube video](https://www.youtube.com/watch?v=AUSNN-RCwhE) or [Docs](https://docs.nocodb.com/FAQs#how-to-check-my-project-info-))
description: Click on <kbd>…</kbd> button next to a base in the NocoDB sidebar on the left and select <kbd>Copy Base Info</kbd> (see the [docs](https://docs.nocodb.com/FAQs#how-to-check-my-project-info-) for details) and paste the result below.
placeholder: |
or provide the following info
Or manually fill in following info:
```
NocoDB used as docker : true / false
NocoDB version :
@ -52,8 +53,8 @@ body:
- type: textarea
attributes:
label: Attachments
description: Add any relevant attachment here
description: Add relevant attachments here.
placeholder: |
> Drag & drop relevant image or videos
Drag & drop relevant images or videos here.
validations:
required: false

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

@ -6,33 +6,33 @@ body:
- type: markdown
attributes:
value: |
Thank you for taking the time to fill out this feature request report!
Thank you for taking the time to fill out this feature request report!
- type: checkboxes
attributes:
label: Please confirm if feature request does NOT exist already ?
description: We kindly ask that you [search](https://github.com/nocodb/nocodb/issues?q=is%3Aissue+sort%3Acreated-desc+) to see if an issue already exists for your feature
label: Please confirm that the feature request does ***not*** already exist
description: We kindly ask you [to search the open issues](https://github.com/nocodb/nocodb/issues?q=is%3Aissue+sort%3Acreated-desc+) and ensure the feature has not already been requested before.
options:
- label: I confirm there is no existing issue for this
- label: I confirm there is no existing issue for this feature request.
required: true
- type: textarea
attributes:
label: Describe the usecase for the feature
description: A clear and concise description of the feature you're interested in.
label: Use case
description: Describe the use case for the requested feature. A clear and concise description of the end result you're interested in.
validations:
required: true
- type: textarea
attributes:
label: Suggested Solution
label: Suggested solution
description: Describe the solution you'd like. A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
attributes:
label: Additional Context
description: Add any other context about the problem here.
label: Additional context
description: Add more context about the problem the requested feature intends to solve.
validations:
required: false

4
.github/workflows/ci-cd.yml

@ -61,7 +61,7 @@ jobs:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node
@ -96,7 +96,7 @@ jobs:
if: ${{ github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'trigger-CI') || !github.event.pull_request.draft }}
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node

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

@ -25,7 +25,7 @@ jobs:
with:
node-version: 18.19.1
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: Get pnpm store directory

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

@ -15,7 +15,7 @@ jobs:
with:
node-version: 18.19.1
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: remove use-node-version from .npmrc

2
.github/workflows/release-docker.yml

@ -46,7 +46,7 @@ jobs:
working-directory: ./packages/nocodb
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: Get Docker Repository

2
.github/workflows/release-npm.yml

@ -38,7 +38,7 @@ jobs:
working-directory: ./packages/nocodb
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: Checkout

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

@ -14,7 +14,7 @@ jobs:
with:
node-version: 18.19.1
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: Checkout

2
.github/workflows/unit-test.yml

@ -25,7 +25,7 @@ jobs:
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/checkout@v3

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

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
- name: Setup Node

2
README.md

@ -75,7 +75,7 @@ Turns any MySQL, PostgreSQL, SQL Server, SQLite & MariaDB into a smart spreadshe
docker run -d --name nocodb-postgres \
-v "$(pwd)"/nocodb:/usr/app/data/ \
-p 8080:8080 \
-e NC_DB="pg://host.docker.internal:5432?u=root&p=password&d=d1" \
-e NC_DB="pg://host.docker.internal:5432?u=root&p=password&d=d1" \
-e NC_AUTH_JWT_SECRET="569a1821-0a93-45e8-87ab-eb857f20a010" \
nocodb/nocodb:latest

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

@ -15,6 +15,8 @@ BOLD='\033[1m'
NC='\033[0m'
NOCO_HOME="./nocodb"
# Get the current working directory
CURRENT_PATH=$(pwd)
# ***************** GLOBAL VARIABLES END ***********************************
# ******************************************************************************
@ -359,6 +361,7 @@ if [ "$NOCO_FOUND" = true ]; then
cd /tmp || exit 1
rm -rf "$NOCO_HOME"
cd "$CURRENT_PATH" || exit 1
mkdir -p "$NOCO_HOME"
cd "$NOCO_HOME" || exit 1
fi
@ -573,6 +576,8 @@ services:
nginx:
image: nginx:latest
labels:
com.nocodb.service: "nginx"
volumes:
- ./nginx:/etc/nginx/conf.d
EOF
@ -603,7 +608,23 @@ if [ "$SSL_ENABLED" = 'y' ] || [ "$SSL_ENABLED" = 'Y' ]; then
- ./letsencrypt:/etc/letsencrypt
- letsencrypt-lib:/var/lib/letsencrypt
- webroot:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait \$\${!}; done;'"
entrypoint: |
/bin/sh -c '
apk add docker-cli || { echo "Failed to install Docker CLI"; exit 1; };
trap exit TERM;
while :; do
OUTPUT=\$\$(certbot renew 2>&1);
echo "\$\$OUTPUT";
if echo "\$\$OUTPUT" | grep -q "No renewals were attempted"; then
echo "No certificates were renewed.";
else
echo "Certificates renewed. Reloading nginx...";
sleep 5;
CONTAINER_NAME=\$\$(docker ps --format "{{.Names}}" --filter "com.nocodb.service=nginx" | grep "nginx") || { echo "Failed to find nginx container"; exit 1; };
docker exec \$\$CONTAINER_NAME nginx -s reload || { echo "Failed to reload nginx"; exit 1; };
fi;
sleep 12h & wait \$\${!};
done;'
depends_on:
- nginx
restart: unless-stopped
@ -819,4 +840,4 @@ read -r MANAGEMENT_MENU
if [ -z "$MANAGEMENT_MENU" ] || { [ "$MANAGEMENT_MENU" != "N" ] && [ "$MANAGEMENT_MENU" != "n" ]; }; then
management_menu
fi
fi

2
package.json

@ -19,7 +19,7 @@
"fs": "0.0.1-security",
"lerna": "^7.4.2",
"husky": "^8.0.3",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz"
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
},
"husky": {
"hooks": {

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

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="camera">
<path id="Vector" d="M15.3333 12.6667C15.3333 13.0203 15.1928 13.3594 14.9428 13.6095C14.6928 13.8595 14.3536 14 14 14H1.99999C1.64637 14 1.30723 13.8595 1.05718 13.6095C0.807132 13.3594 0.666656 13.0203 0.666656 12.6667V5.33333C0.666656 4.97971 0.807132 4.64057 1.05718 4.39052C1.30723 4.14048 1.64637 4 1.99999 4H4.66666L5.99999 2H9.99999L11.3333 4H14C14.3536 4 14.6928 4.14048 14.9428 4.39052C15.1928 4.64057 15.3333 4.97971 15.3333 5.33333V12.6667Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M8.00001 11.3333C9.47277 11.3333 10.6667 10.1394 10.6667 8.66667C10.6667 7.19391 9.47277 6 8.00001 6C6.52725 6 5.33334 7.19391 5.33334 8.66667C5.33334 10.1394 6.52725 11.3333 8.00001 11.3333Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 982 B

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

@ -1,7 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.33341 1.33333H4.00008C3.64646 1.33333 3.30732 1.4738 3.05727 1.72385C2.80722 1.9739 2.66675 2.31304 2.66675 2.66666V13.3333C2.66675 13.6869 2.80722 14.0261 3.05727 14.2761C3.30732 14.5262 3.64646 14.6667 4.00008 14.6667H12.0001C12.3537 14.6667 12.6928 14.5262 12.9429 14.2761C13.1929 14.0261 13.3334 13.6869 13.3334 13.3333V5.33333L9.33341 1.33333Z" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6666 11.3333H5.33325" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6666 8.66667H5.33325" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66659 6H5.99992H5.33325" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33325 1.33333V5.33333H13.3333" stroke="#4A5268" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33341 1.33333H4.00008C3.64646 1.33333 3.30732 1.4738 3.05727 1.72385C2.80722 1.9739 2.66675 2.31304 2.66675 2.66666V13.3333C2.66675 13.6869 2.80722 14.0261 3.05727 14.2761C3.30732 14.5262 3.64646 14.6667 4.00008 14.6667H12.0001C12.3537 14.6667 12.6928 14.5262 12.9429 14.2761C13.1929 14.0261 13.3334 13.6869 13.3334 13.3333V5.33333L9.33341 1.33333Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6666 11.3333H5.33325" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6666 8.66667H5.33325" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66659 6H5.99992H5.33325" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33325 1.33333V5.33333H13.3333" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

2
packages/nc-gui/components/api-client/Headers.vue

@ -62,7 +62,7 @@ const filterOption = (input: string, option: Option) => option.value.toUpperCase
<table class="w-full nc-webhooks-params">
<thead class="h-8">
<tr>
<th></th>
<th class="w-8"></th>
<th>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('labels.headerName') }}</div>
</th>

11
packages/nc-gui/components/api-client/Params.vue

@ -21,6 +21,7 @@ const deleteParamRow = (i: number) => {
<table class="w-full nc-webhooks-params">
<thead class="h-8">
<tr>
<th class="w-8"></th>
<th>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('title.parameterName') }}</div>
</th>
@ -28,15 +29,17 @@ const deleteParamRow = (i: number) => {
<th>
<div class="text-left font-normal ml-2" data-rec="true">{{ $t('placeholder.value') }}</div>
</th>
<th class="w-8">
<!-- Intended to be empty - For delete button -->
</th>
<th class="w-8"></th>
</tr>
</thead>
<tbody>
<tr v-for="(paramRow, idx) in vModel" :key="idx" class="!h-2 overflow-hidden">
<td class="px-2">
<a-form-item class="form-item">
<a-checkbox v-model:checked="paramRow.enabled" />
</a-form-item>
</td>
<td class="px-2">
<a-form-item class="form-item">
<a-input v-model:value="paramRow.name" :placeholder="$t('placeholder.key')" class="!rounded-lg" />

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

@ -54,16 +54,6 @@ const localValue = computed<ModelValueType>({
},
})
const clear = () => {
error.value = undefined
isExpanded.value = false
editEnabled.value = false
localValue.value = vModel.value
}
const formatJson = (json: string) => {
try {
return JSON.stringify(JSON.parse(json))
@ -73,21 +63,30 @@ const formatJson = (json: string) => {
}
}
const onSave = () => {
function setLocalValue(val: any) {
try {
localValue.value = formatValue(val) === null ? null : typeof val === 'string' ? JSON.stringify(JSON.parse(val), null, 2) : val
} catch (e) {
localValue.value = formatValue(val) === null ? null : val
}
}
const clear = () => {
error.value = undefined
isExpanded.value = false
editEnabled.value = false
vModel.value = formatValue(localValue.value) === null ? null : formatJson(localValue.value as string)
setLocalValue(vModel.value)
}
const setLocalValue = (val: any) => {
try {
localValue.value =
formatValue(localValue.value) === null ? null : typeof val === 'string' ? JSON.stringify(JSON.parse(val), null, 2) : val
} catch (e) {
localValue.value = formatValue(localValue.value) === null ? null : val
}
const onSave = () => {
isExpanded.value = false
editEnabled.value = false
vModel.value = formatValue(localValue.value) === null ? null : formatJson(localValue.value as string)
}
watch(

135
packages/nc-gui/components/cell/attachment/AttachFile.vue

@ -0,0 +1,135 @@
<script setup lang="ts">
import { useAttachmentCell } from './utils'
const props = defineProps<{
value: boolean
}>()
const dialogShow = useVModel(props, 'value')
const { onDrop: saveAttachment, isPublic, stopCamera } = useAttachmentCell()!
const activeMenu = ref('local')
const selectMenu = (option: string) => {
activeMenu.value = option
}
const closeModal = (value: boolean) => {
dialogShow.value = value
}
const saveAttachments = async (files: File[]) => {
await saveAttachment(files, {} as any)
dialogShow.value = false
}
watch(activeMenu, (newVal, oldValue) => {
// Stop camera when switching to another menu
if (oldValue === 'webcam' && newVal !== 'webcam') {
// When the menu is switched when the startCamera function is called, the videoStream might not have initialized yet
// So, we need to wait for a while before stopping the camera
setTimeout(() => {
stopCamera()
}, 1000)
}
})
</script>
<template>
<NcModal
v-model:visible="dialogShow"
:show-separator="false"
size="medium"
width="50rem"
wrap-class-name="nc-modal-attachment-create"
class="!rounded-md"
@keydown.esc="dialogShow = false"
>
<div class="flex h-full flex-row">
<div style="border-top-left-radius: 1rem; border-bottom-left-radius: 1rem" class="px-2 !-full flex-grow bg-gray-100">
<NcMenu class="!h-full !bg-gray-100">
<NcMenuItem
key="local"
:class="{
'active-menu': activeMenu === 'local',
}"
@click="selectMenu('local')"
>
<div class="flex gap-2 items-center">
<GeneralIcon icon="file" />
{{ $t('title.localFiles') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="!isPublic"
key="url"
:class="{
'active-menu': activeMenu === 'url',
}"
@click="selectMenu('url')"
>
<div class="flex gap-2 items-center">
<GeneralIcon icon="link2" />
{{ $t('title.uploadViaUrl') }}
</div>
</NcMenuItem>
<NcMenuItem
key="webcam"
:class="{
'active-menu': activeMenu === 'webcam',
}"
@click="selectMenu('webcam')"
>
<div class="flex gap-2 items-center">
<GeneralIcon icon="camera" />
{{ $t('title.webcam') }}
</div>
</NcMenuItem>
</NcMenu>
</div>
<div style="height: 425px" class="w-full p-2">
<LazyCellAttachmentUploadProvidersLocal
v-show="activeMenu === 'local'"
@update:visible="closeModal"
@upload="(e) => saveAttachments(e)"
/>
<LazyCellAttachmentUploadProvidersCamera
v-if="activeMenu === 'webcam'"
@update:visible="closeModal"
@upload="(e) => saveAttachments(e)"
/>
<LazyCellAttachmentUploadProvidersUrl
v-if="activeMenu === 'url'"
@update:visible="closeModal"
@upload="(e) => saveAttachments(e)"
/>
</div>
</div>
</NcModal>
</template>
<style lang="scss">
.nc-modal-attachment-create {
.active-menu {
@apply !bg-gray-200 font-sembold text-brand-500 rounded-md;
}
}
.nc-modal-attachment-create {
.nc-modal {
@apply !p-0;
}
}
</style>
<style scoped lang="scss">
:deep(.ant-menu-inline),
:deep(.ant-menu-vertical),
:deep(.ant-menu-vertical-left) {
border-right: none !important;
}
</style>

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

@ -92,22 +92,27 @@ const handleFileDelete = (i: number) => {
>
<template #title>
<div class="flex gap-4">
<div
<NcButton
v-if="isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)"
class="nc-attach-file group"
data-testid="attachment-expand-file-picker-button"
@click="open"
>
<MaterialSymbolsAttachFile class="transform group-hover:(text-accent scale-120)" />
{{ $t('activity.attachFile') }}
</div>
<div class="flex gap-2 items-center">
<component :is="iconMap.cellAttachment" class="w-4 h-4" />
{{ $t('activity.attachFile') }}
</div>
</NcButton>
<div class="flex items-center gap-2">
{{ $t('labels.viewingAttachmentsOf') }}
<div class="font-semibold underline">{{ column?.title }}</div>
</div>
<div v-if="selectedVisibleItems.includes(true)" class="flex flex-1 items-center gap-3 justify-end mr-[30px]">
<div
v-if="selectedVisibleItems.includes(true) && selectedVisibleItems.length > 1"
class="flex flex-1 items-center gap-3 justify-end mr-[30px]"
>
<NcButton type="primary" class="nc-attachment-download-all" @click="bulkDownloadFiles">
{{ $t('activity.bulkDownload') }}
</NcButton>
@ -127,40 +132,14 @@ const handleFileDelete = (i: number) => {
</template>
<div ref="sortableRef" :class="{ dragging }" class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 relative p-6">
<div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col gap-1">
<div v-for="(item, i) of visibleItems" :key="`${item.title}-${i}`" class="flex flex-col group gap-1">
<a-card class="nc-attachment-item group">
<a-checkbox
<NcCheckbox
v-model:checked="selectedVisibleItems[i]"
class="nc-attachment-checkbox group-hover:(opacity-100)"
class="nc-attachment-checkbox absolute top-2 left-2 group-hover:(opacity-100)"
:class="{ '!opacity-100': selectedVisibleItems[i] }"
/>
<a-tooltip v-if="!readOnly">
<template #title> {{ $t('title.removeFile') }} </template>
<component
:is="iconMap.closeCircle"
v-if="isSharedForm || (isUIAllowed('dataEdit') && !isPublic)"
class="nc-attachment-remove"
@click.stop="onRemoveFileClick(item.title, i)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template>
<div class="nc-attachment-download group-hover:(opacity-100)">
<component :is="iconMap.download" @click.stop="downloadFile(item)" />
</div>
</a-tooltip>
<a-tooltip v-if="isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<div class="nc-attachment-rename group-hover:(opacity-100) mr-[35px]">
<component :is="iconMap.rename" @click.stop="renameFile(item, i)" />
</div>
</a-tooltip>
<div
:class="[dragging ? 'cursor-move' : 'cursor-pointer']"
class="nc-attachment h-full w-full flex items-center justify-center overflow-hidden"
@ -183,9 +162,32 @@ const handleFileDelete = (i: number) => {
<IcOutlineInsertDriveFile v-else height="150" width="150" @click.stop="openAttachment(item)" />
</div>
</a-card>
<div class="truncate" :title="item.title">
{{ item.title }}
<div class="relative flex" :title="item.title">
<div class="flex-auto truncate line-height-4">
{{ item.title }}
</div>
<div class="flex-none hide-ui transition-all transition-ease-in-out !h-6 flex items-center bg-white">
<NcTooltip placement="bottom">
<template #title> {{ $t('title.downloadFile') }} </template>
<NcButton class="!text-gray-500" size="xsmall" type="text" @click="downloadFile(item)">
<component :is="iconMap.download" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isSharedForm || (!readOnly && isUIAllowed('dataEdit') && !isPublic)" placement="bottom">
<template #title> {{ $t('title.renameFile') }} </template>
<NcButton size="xsmall" class="nc-attachment-rename !text-gray-500" type="text" @click="renameFile(item, i)">
<component :is="iconMap.rename" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="!readOnly" placement="bottom">
<template #title> {{ $t('title.removeFile') }} </template>
<NcButton class="!text-red-500" size="xsmall" type="text" @click="onRemoveFileClick(item.title, i)">
<component :is="iconMap.delete" v-if="isSharedForm || (isUIAllowed('dataEdit') && !isPublic)" />
</NcButton>
</NcTooltip>
</div>
</div>
</div>
@ -217,13 +219,15 @@ const handleFileDelete = (i: number) => {
</template>
<style lang="scss">
.nc-attachment-modal {
.nc-attach-file {
@apply select-none cursor-pointer color-transition flex items-center gap-1 border-1 p-2 rounded
@apply hover:(bg-primary bg-opacity-10 text-primary ring);
@apply active:(ring-accent ring-opacity-100 bg-primary bg-opacity-20);
}
.hide-ui {
@apply h-0 w-0 overflow-hidden whitespace-nowrap;
// When the parent with class 'group' is hovered
.group:hover & {
@apply h-auto w-auto overflow-visible whitespace-normal;
}
}
.nc-attachment-modal {
.nc-attachment-item {
@apply !h-2/3 !min-h-[200px] flex items-center justify-center relative;
@ -238,35 +242,15 @@ const handleFileDelete = (i: number) => {
@supports (-moz-appearance: none) {
&:hover::after {
@apply ring shadow transform scale-103;
@apply ring shadow;
}
&:active::after {
@apply ring ring-accent ring-opacity-100 shadow transform scale-103;
@apply ring ring-accent ring-opacity-100 shadow;
}
}
}
.nc-attachment-download,
.nc-attachment-rename {
@apply bg-white absolute bottom-2 right-2;
@apply transition-opacity duration-150 ease-in opacity-0 hover:ring;
@apply cursor-pointer rounded shadow flex items-center p-1 border-1;
@apply active:(ring border-0 ring-accent);
}
.nc-attachment-checkbox {
@apply absolute top-2 left-2;
@apply transition-opacity duration-150 ease-in opacity-0;
}
.nc-attachment-remove {
@apply absolute top-2 right-2 bg-white;
@apply hover:(ring ring-red-500);
@apply cursor-pointer rounded-full border-2;
@apply active:(ring border-0 ring-red-500);
}
.ant-card-body {
@apply !p-2 w-full h-full;
}
@ -275,19 +259,10 @@ const handleFileDelete = (i: number) => {
@apply !p-0;
}
.ghost,
.ghost > * {
@apply !pointer-events-none;
}
.dragging {
.nc-attachment-item {
@apply !pointer-events-none;
}
.ant-tooltip {
@apply !hidden;
}
}
}
</style>

147
packages/nc-gui/components/cell/attachment/UploadProviders/Camera.vue

@ -0,0 +1,147 @@
<script setup lang="ts">
import { useAttachmentCell } from '../utils'
const emits = defineEmits<{
'update:visible': [value: boolean]
'upload': [fileList: File[]]
}>()
const { isLoading, startCamera: _startCamera, stopCamera: _stopCamera, videoStream, permissionGranted } = useAttachmentCell()!
const capturedImage = ref<null | File>(null)
const videoRef = ref<HTMLVideoElement | undefined>()
const canvasRef = ref<HTMLCanvasElement | undefined>()
const startCamera = async () => {
try {
await _startCamera()
if (!videoRef.value || !videoStream.value) return
videoRef.value.srcObject = videoStream.value
} catch (error) {}
}
const stopCamera = () => {
_stopCamera()
if (videoRef.value) {
videoRef.value.srcObject = null
}
}
const retakeImage = () => {
capturedImage.value = null
startCamera()
}
const captureImage = () => {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas) return
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
if (context) {
canvas.style.display = 'block'
context.translate(canvas.width, 0)
context.scale(-1, 1)
context.drawImage(video, 0, 0, canvas.width, canvas.height)
canvas.toBlob((blob) => {
if (!blob) return
capturedImage.value = new File([blob], `${new Date().toDateString()}.png`, { type: 'image/png' })
}, 'image/png')
stopCamera()
}
}
const closeMenu = () => {
emits('update:visible', false)
}
onMounted(() => {
startCamera()
})
onBeforeUnmount(() => {
stopCamera()
})
</script>
<template>
<div class="w-full relative h-full">
<NcTooltip class="absolute top-3 right-2">
<NcButton type="text" class="!border-0" size="xsmall" @click="closeMenu">
<GeneralIcon icon="close" />
</NcButton>
<template #title> {{ $t('general.close') }} </template>
</NcTooltip>
<div v-if="!permissionGranted" class="w-full h-full flex bg-gray-50 items-center justify-center">
<div
class="flex flex-col hover:bg-white p-2 cursor-pointer rounded-md !transition-all transition-ease-in-out duration-300 gap-2 items-center justify-center"
@click="startCamera"
>
<div class="p-5 bg-white rounded-md shadow-sm">
<mdi-camera class="text-4xl text-gray-800" />
</div>
<h1 class="text-gray-800 font-semibold text-center text-xl">
{{ $t('labels.allowAccessToYourCamera') }}
</h1>
</div>
</div>
<div
v-else
:class="{
'py-8': !capturedImage,
'pt-8 pb-2': capturedImage,
}"
class="w-full gap-3 h-full flex-col flex items-center justify-between"
>
<div v-show="!capturedImage" class="w-full gap-3 h-full flex-col flex items-center justify-between">
<video ref="videoRef" class="rounded-md" style="width: 400px" autoplay></video>
<NcButton class="!rounded-full !px-0" @click="captureImage">
<mdi-camera class="text-xl" />
</NcButton>
</div>
<div v-show="capturedImage" class="flex group flex-col gap-1">
<canvas ref="canvasRef" class="rounded-md" style="width: 400px; display: none"></canvas>
<div class="relative text-[12px] font-semibold text-gray-800 flex">
<div class="flex-auto truncate line-height-4">
{{ capturedImage?.name }}
</div>
<div class="flex-none hide-ui transition-all transition-ease-in-out !h-4 flex items-center bg-white">
<NcTooltip placement="bottom">
<template #title> {{ $t('title.removeFile') }} </template>
<component :is="iconMap.delete" class="!text-red-500 cursor-pointer" @click="retakeImage" />
</NcTooltip>
</div>
</div>
<div class="flex-none text-[10px] font-semibold text-gray-500">
{{ formatBytes(capturedImage?.size, 0) }}
</div>
</div>
<div v-show="capturedImage" class="flex gap-2 pr-2 bottom-1 relative w-full items-center justify-end">
<NcButton :disabled="isLoading" type="secondary" size="small" @click="closeMenu">
{{ $t('labels.cancel') }}
</NcButton>
<NcButton :loading="isLoading" size="small" @click="emits('upload', [capturedImage] as File[])">
<template v-if="!isLoading"> {{ $t('labels.uploadImage') }} </template>
<template v-else> {{ $t('labels.uploading') }} </template>
</NcButton>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
video {
-webkit-transform: scaleX(-1);
transform: scaleX(-1);
}
</style>

190
packages/nc-gui/components/cell/attachment/UploadProviders/Local.vue

@ -0,0 +1,190 @@
<script setup lang="ts">
import { useAttachmentCell } from '../utils'
const emits = defineEmits<{
'update:visible': [value: boolean]
'upload': [fileList: File[]]
}>()
const dropZoneRef = ref<HTMLDivElement>()
const tempFiles = ref<File[]>([])
const { isLoading } = useAttachmentCell()!
const { files, open: _open } = useFileDialog({
reset: true,
})
watch(files, (newFiles) => {
if (!newFiles) return
Object.values(newFiles).forEach((file) => {
tempFiles.value.push(file)
})
})
const onDrop = (files: File[], event: DragEvent) => {
tempFiles.value.push(...files)
event.preventDefault()
event.stopPropagation()
}
const thumbnails = computedAsync(async () => {
const map = new Map()
await Promise.all(
tempFiles.value.map(async (file) => {
const thumbnail = await createThumbnail(file)
if (thumbnail) {
map.set(file, thumbnail)
}
}),
)
return map
})
const onRemoveFileClick = (file: File) => {
tempFiles.value = tempFiles.value.filter((f) => f !== file)
}
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
const clearAll = () => {
tempFiles.value = []
}
const open = () => {
_open()
}
const closeMenu = () => {
emits('update:visible', false)
}
onBeforeUnmount(() => {
tempFiles.value = []
})
</script>
<template>
<div
:class="{
'flex flex-col relative justify-center items-center': !tempFiles.length,
}"
class="w-full p-2 h-full"
>
<NcTooltip v-if="tempFiles.length === 0" class="absolute top-3 right-3">
<NcButton type="text" class="!border-0" size="xsmall" @click="closeMenu">
<GeneralIcon icon="close" />
</NcButton>
<template #title> {{ $t('general.close') }} </template>
</NcTooltip>
<div v-if="tempFiles.length > 0" class="flex w-full border-b-1 py-1 h-9.5 items-center justify-between top-0">
<NcButton type="text" size="small" @click="clearAll">
{{ $t('labels.clearAllFiles') }}
</NcButton>
<span class="text-xs">
{{ tempFiles.length }} files, total size:
{{
formatBytes(
tempFiles.reduce((acc, file) => acc + file.size, 0),
0,
)
}}
</span>
<NcButton type="text" size="small" @click="open">
<div class="flex gap-1 items-center">
<component :is="iconMap.plus" />
{{ $t('labels.addMore') }}
</div>
</NcButton>
</div>
<div
ref="dropZoneRef"
:class="{
'border-brand-500': isOverDropZone,
'border-dashed border-1': !tempFiles.length,
}"
data-testid="attachment-drop-zone"
:style="`height: ${tempFiles.length > 0 ? '324px' : '100%'}`"
class="flex flex-col items-center justify-center h-full w-full flex-grow-1 rounded-lg"
@click="tempFiles.length > 0 ? () => {} : open()"
>
<div v-if="!tempFiles.length" class="flex cursor-pointer items-center justify-center flex-col gap-2">
<template v-if="!isOverDropZone">
<component :is="iconMap.upload" class="w-5 h-5" />
<h1>
{{ $t('labels.clickTo') }}
<span class="font-semibold"> {{ $t('labels.browseFiles') }} </span>
{{ $t('general.or') }}
<span class="font-semibold"> {{ $t('labels.dragFilesHere') }} </span>
{{ $t('labels.toUpload') }}
</h1>
</template>
<template v-if="isOverDropZone">
<component :is="iconMap.upload" class="w-5 text-brand-500 h-5" />
<h1 class="text-brand-500 font-bold">{{ $t('labels.dropHere') }}</h1>
</template>
</div>
<template v-else>
<div
class="grid overflow-y-auto flex-grow-1 nc-scrollbar-md grid-cols-4 w-full h-full items-start py-2 justify-center gap-4"
>
<div v-for="file in tempFiles" :key="file.name" class="flex gap-1.5 group min-w-34 max-w-28 pb-4 flex-col relative">
<div
v-if="!thumbnails.get(file)"
style="height: 140px"
class="flex items-center justify-center rounded-md bg-gray-300"
>
<component :is="iconMap.file" class="w-16 h-16" />
</div>
<img v-else :src="thumbnails.get(file)" style="height: 140px" alt="thumbnail" class="rounded-md object-cover" />
<div class="relative text-[12px] font-semibold items-center text-gray-800 flex">
<NcTooltip class="flex-auto truncate" placement="bottom">
<template #title> {{ file.name }} </template>
{{ file.name }}
</NcTooltip>
<div class="flex-none hide-ui transition-all transition-ease-in-out !h-4 flex items-center bg-white">
<NcTooltip placement="bottom">
<template #title> {{ $t('title.removeFile') }} </template>
<component :is="iconMap.delete" class="!text-red-500 w-3 h-3 cursor-pointer" @click="onRemoveFileClick(file)" />
</NcTooltip>
</div>
</div>
<div class="flex-none text-[10px] font-semibold text-gray-500">
{{ formatBytes(file.size, 0) }}
</div>
</div>
</div>
</template>
</div>
<div v-if="tempFiles.length" class="flex gap-2 pt-1 bg-white w-full items-center justify-end">
<NcButton :disabled="isLoading" type="secondary" size="small" @click="closeMenu">
{{ $t('labels.cancel') }}
</NcButton>
<NcButton :loading="isLoading" data-testid="nc-upload-file" size="small" @click="emits('upload', tempFiles)">
<template v-if="isLoading">
{{ $t('labels.uploading') }}
</template>
<template v-else> {{ $t('general.upload') }} {{ tempFiles.length }} {{ $t('objects.files') }} </template>
</NcButton>
</div>
</div>
</template>
<style lang="scss">
.hide-ui {
@apply h-0 w-0 overflow-hidden whitespace-nowrap;
.group:hover & {
@apply h-auto w-auto overflow-visible whitespace-normal;
}
}
</style>

173
packages/nc-gui/components/cell/attachment/UploadProviders/Url.vue

@ -0,0 +1,173 @@
<script setup lang="ts">
import { useAttachmentCell } from '../utils'
const emits = defineEmits<{
'update:visible': [value: boolean]
}>()
const { openAttachment } = useAttachment()
const { uploadViaUrl, updateModelValue } = useAttachmentCell()!
const closeMenu = () => {
emits('update:visible', false)
}
const inputRef = ref<HTMLInputElement | null>(null)
const tempAttachments = ref<
{
url?: string
mimetype: string
title: string
path?: string
size: number
}[]
>([])
const onSave = async () => {
updateModelValue(tempAttachments.value)
closeMenu()
}
const url = ref('')
const isParsing = ref(false)
const deleteAttachment = (index: number) => {
tempAttachments.value.splice(index, 1)
}
const isValidUrl = ref(true)
const errorMessage = ref('')
const uploadAndParseUrl = async () => {
if (!isValidURL(url.value)) {
isValidUrl.value = false
return
}
isValidUrl.value = true
try {
isParsing.value = true
const data = await uploadViaUrl({ url: url.value }, true)
if (typeof data !== 'string' && data?.length) {
tempAttachments.value = [...data, ...tempAttachments.value]
url.value = ''
} else {
isValidUrl.value = false
errorMessage.value = data
}
} finally {
isParsing.value = false
}
await nextTick(() => {
inputRef.value?.focus()
})
}
watch(url, () => {
isValidUrl.value = true
})
</script>
<template>
<div class="py-2 px-2 h-full flex gap-2 flex-col">
<div class="flex w-full bg-white border-b-1 py-1 justify-between">
<h1 class="font-semibold">
{{ $t('title.uploadViaUrl') }}
</h1>
<NcTooltip>
<NcButton type="secondary" class="!border-0" size="xsmall" @click="closeMenu">
<GeneralIcon icon="close" />
</NcButton>
<template #title> {{ $t('general.close') }} </template>
</NcTooltip>
</div>
<div class="flex-grow bg-white">
<h1 class="text-gray-800 font-semibold">
{{ $t('labels.addFilesFromUrl') }}
</h1>
<div class="flex bg-white gap-2">
<a-input
ref="inputRef"
v-model:value="url"
type="url"
:disabled="isParsing"
class="flex-grow"
placeholder="www.google.com/hello.png"
@keydown.enter="uploadAndParseUrl"
/>
<NcButton :disabled="!isValidUrl" :loading="isParsing" size="small" class="!h-10 !px-4" @click="uploadAndParseUrl">
{{ $t('general.upload') }}
</NcButton>
</div>
<span v-if="url.length > 0 && !isValidUrl" class="text-red-500 text-[13px]">
{{ errorMessage.length > 0 ? errorMessage : $t('labels.enterValidUrl') }}
</span>
<template v-if="tempAttachments.length > 0">
<div :style="`height: ${!isValidUrl ? '208px' : '230px'}`" class="overflow-y-auto bg-white mt-1 !max-h-[250px]">
<h1 class="font-semibold capitalize sticky top-0 bg-white text-gray-800">
{{ $t('objects.files') }}
</h1>
<div
v-for="(file, index) in tempAttachments"
:key="index"
class="flex w-full items-center mt-2 h-10 px-2 py-1 border-1 rounded-md"
>
<div class="flex w-full items-center gap-2">
<GeneralIcon icon="file" />
{{ file.title }}
<NcTooltip class="hover:underline">
<NuxtLink class="flex items-center" target="_blank" @click="openAttachment(file)">
<component :is="iconMap.externalLink" class="w-3.5 h-3.5 text-gray-500" />
</NuxtLink>
<template #title> {{ $t('labels.openFile') }} </template>
</NcTooltip>
</div>
<div class="flex-grow-1"></div>
<NcTooltip>
<template #title> {{ $t('title.removeFile') }} </template>
<NcButton type="text" size="xsmall" @click="deleteAttachment(index)">
<GeneralIcon icon="close" />
</NcButton>
</NcTooltip>
</div>
</div>
</template>
</div>
<div class="flex gap-2 items-center justify-end">
<NcButton :disabled="isParsing" type="secondary" size="small" @click="closeMenu"> {{ $t('labels.cancel') }} </NcButton>
<NcButton :disabled="isParsing || tempAttachments.length === 0" size="small" @click="onSave">
{{ $t('activity.addFiles') }}</NcButton
>
</div>
</div>
</template>
<style scoped lang="scss">
.ant-input::placeholder {
@apply text-gray-500;
}
.ant-input {
@apply px-4 rounded-lg py-2 w-full border-1 focus:border-brand-500 border-gray-200 !ring-0;
}
a {
@apply !text-gray-700 !no-underline !hover:underline;
}
</style>

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

@ -49,7 +49,6 @@ const {
visibleItems,
onDrop,
isLoading,
open: _open,
FileIcon,
selectedImage,
isReadonly,
@ -118,8 +117,11 @@ watch(
},
)
const isNewAttachmentModalOpen = ref(false)
useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
if (e.key === 'Enter' && !isReadonly.value) {
if (isNewAttachmentModalOpen.value) return
e.stopPropagation()
if (!modalVisible.value && !isMobileMode.value) {
modalVisible.value = true
@ -132,10 +134,14 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e) => {
const rowHeight = inject(RowHeightInj, ref())
const openAttachmentModal = () => {
isNewAttachmentModalOpen.value = true
}
const open = (e: Event) => {
e.stopPropagation()
_open()
openAttachmentModal()
}
const openAttachment = (item: any) => {
@ -207,7 +213,7 @@ const handleFileDelete = (i: number) => {
isGrid ? '22px' : '32px'
})`,
}"
class="nc-attachment-cell relative flex color-transition flex items-center w-full xs:(min-h-12 max-h-32)"
class="nc-attachment-cell relative flex color-transition gap-2 flex items-center w-full xs:(min-h-12 max-h-32)"
:class="{ 'justify-center': !active, 'justify-between': active, 'px-2': isExpandedForm }"
>
<LazyCellAttachmentCarousel />
@ -228,7 +234,7 @@ const handleFileDelete = (i: number) => {
<div
v-if="!isReadonly"
:class="{ 'sm:(mx-auto px-4) xs:(w-full min-w-8)': !visibleItems.length }"
class="group cursor-pointer py-1 flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
class="group cursor-pointer py-1 flex gap-1 items-center rounded border-none shadow-sm hover:(bg-primary bg-opacity-10)"
data-testid="attachment-cell-file-picker-button"
tabindex="0"
@click="open"
@ -246,9 +252,7 @@ const handleFileDelete = (i: number) => {
v-if="active || !visibleItems.length || (isForm && visibleItems.length)"
class="flex items-center gap-1 xs:(w-full min-w-12 h-7 justify-center)"
>
<MaterialSymbolsAttachFile
class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-tiny"
/>
<MaterialSymbolsAttachFile class="text-gray-500 text-tiny" />
<div
v-if="!visibleItems.length"
data-rec="true"
@ -268,9 +272,9 @@ const handleFileDelete = (i: number) => {
:class="{
'justify-center': !isExpandedForm && !isGallery && !isKanban,
'py-1': rowHeight === 1 && !isForm && !isExpandedForm,
'py-1.5': rowHeight !== 1 || isForm || isExpandedForm,
'py-1.5 !gap-4 ': rowHeight !== 1 || isForm || isExpandedForm,
}"
class="nc-attachment-wrapper flex cursor-pointer w-full items-center flex-wrap gap-2 nc-scrollbar-thin mt-0 items-start px-[1px]"
class="nc-attachment-wrapper flex cursor-pointer w-full items-center flex-wrap gap-3 nc-scrollbar-thin mt-0 items-start px-[1px]"
:style="{
maxHeight: isForm || isExpandedForm ? undefined : `max(100%, ${isGrid ? '22px' : '32px'})`,
}"
@ -283,7 +287,7 @@ const handleFileDelete = (i: number) => {
<div v-if="isImage(item.title, item.mimetype ?? item.type)">
<div
class="nc-attachment flex items-center flex-col flex-wrap justify-center flex-auto"
:class="{ 'ml-2': active, '!w-30': isForm || isExpandedForm }"
:class="{ '!w-30': isForm || isExpandedForm }"
@click="() => onImageClick(item)"
>
<LazyCellAttachmentImage
@ -333,18 +337,16 @@ const handleFileDelete = (i: number) => {
<div
v-if="active || (isForm && visibleItems.length)"
class="xs:hidden h-6 w-5.5 group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none p-1 hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
class="xs:hidden group cursor-pointer flex gap-1 items-center rounded border-none p-1"
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<NcTooltip v-else placement="bottom" class="flex">
<template #title> {{ $t('activity.viewAttachment') }}</template>
<component
:is="iconMap.expand"
class="flex-none transform dark:(!text-white) group-hover:(!text-grey-800 scale-120) text-gray-500 text-sm"
@click.stop="onExpand"
/>
<NcButton type="text" size="xsmall" @click.stop="onExpand">
<component :is="iconMap.expand" />
</NcButton>
</NcTooltip>
</div>
</template>
@ -372,6 +374,7 @@ const handleFileDelete = (i: number) => {
</template>
</LazyGeneralDeleteModal>
</div>
<LazyCellAttachmentAttachFile v-if="isNewAttachmentModalOpen" v-model:value="isNewAttachmentModalOpen" />
</template>
<style lang="scss">

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

@ -35,6 +35,10 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
/** for image carousel */
const selectedImage = ref()
const videoStream = ref<MediaStream | null>(null)
const permissionGranted = ref(false)
const { base } = storeToRefs(useBase())
const { api, isLoading } = useApi()
@ -57,6 +61,18 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}),
}
const startCamera = async () => {
if (!videoStream.value) {
videoStream.value = await navigator.mediaDevices.getUserMedia({ video: true })
}
permissionGranted.value = true
}
const stopCamera = () => {
videoStream.value?.getTracks().forEach((track) => track.stop())
videoStream.value = null
}
/** our currently visible items, either the locally stored or the ones from db, depending on isPublic & isForm status */
const visibleItems = computed<any[]>(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value))
@ -214,22 +230,33 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
message.error(e.message || t('msg.error.internalError'))
}
} else if (imageUrls.length) {
try {
const data = await api.storage.uploadByUrl(
{
path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'),
},
imageUrls,
)
newAttachments.push(...data)
} catch (e: any) {
message.error(e.message || t('msg.error.internalError'))
}
const data = uploadViaUrl(imageUrls)
if (!data) return
newAttachments.push(...data)
}
updateModelValue(JSON.stringify([...attachments.value, ...newAttachments]))
}
async function uploadViaUrl(url: AttachmentReqType | AttachmentReqType[], returnError = false) {
const imageUrl = Array.isArray(url) ? url : [url]
try {
const data = await api.storage.uploadByUrl(
{
path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'),
},
imageUrl,
)
return data
} catch (e: any) {
console.log(e)
if (returnError) {
return "File couldn't be uploaded. Verify URL & try again."
}
message.error("File couldn't be uploaded. Verify URL & try again.")
return null
}
}
async function renameFile(attachment: AttachmentType, idx: number) {
return new Promise<boolean>((resolve) => {
const { close } = useDialog(RenameFile, {
@ -360,10 +387,15 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
downloadFile,
updateModelValue,
selectedImage,
uploadViaUrl,
selectedVisibleItems,
storedFiles,
bulkDownloadFiles,
defaultAttachmentMeta,
startCamera,
stopCamera,
videoStream,
permissionGranted,
}
},
'useAttachmentCell',

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

@ -55,6 +55,7 @@ onMounted(() => {
<template>
<div class="flex w-full flex-col py-0.9 px-1 border-gray-200 gap-y-1">
<LazyGeneralMaintenanceAlert />
<div class="flex items-center pr-2 justify-between">
<NcDropdown v-model:visible="isMenuOpen" placement="topLeft" overlay-class-name="!min-w-64">
<div

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

@ -348,7 +348,7 @@ const source = computed(() => {
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.table') }}
{{ $t('general.rename') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
@ -365,7 +365,7 @@ const source = computed(() => {
>
<div v-e="['c:table:duplicate']" class="flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }} {{ $t('objects.table') }}
{{ $t('general.duplicate') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
@ -378,7 +378,7 @@ const source = computed(() => {
>
<div v-e="['c:table:delete']" class="flex gap-2 items-center">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table') }}
{{ $t('general.delete') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
</template>

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

@ -5,6 +5,8 @@ import 'splitpanes/dist/splitpanes.css'
const router = useRouter()
const route = router.currentRoute
const { setLeftSidebarSize } = useGlobal()
const { isMobileMode } = storeToRefs(useConfigStore())
const {
@ -30,22 +32,32 @@ const currentSidebarSize = computed({
const { handleSidebarOpenOnMobileForNonViews } = useConfigStore()
const contentSize = computed(() => 100 - sideBarSize.value.current)
const mobileNormalizedContentSize = computed(() => {
if (isMobileMode.value) {
return isLeftSidebarOpen.value ? 0 : 100
}
return contentSize.value
return 100 - leftSidebarWidthPercent.value
})
const sidebarWidth = computed(() =>
isMobileMode.value ? viewportWidth.value : (sideBarSize.value.old * viewportWidth.value) / 100,
)
watch(currentSidebarSize, () => {
leftSidebarWidthPercent.value = currentSidebarSize.value
leftSidebarWidthPercent.value = (currentSidebarSize.value / viewportWidth.value) * 100
setLeftSidebarSize({ current: currentSidebarSize.value, old: sideBarSize.value.old })
})
const sidebarWidth = computed(() => (isMobileMode.value ? viewportWidth.value : sideBarSize.value.old))
const normalizedWidth = computed(() => {
const maxSize = remToPx(viewportWidth.value <= 1560 ? 20 : 35)
const minSize = remToPx(16)
if (sidebarWidth.value > maxSize) {
return maxSize
} else if (sidebarWidth.value < minSize) {
return minSize
} else {
return sidebarWidth.value
}
})
watch(isLeftSidebarOpen, () => {
@ -87,10 +99,20 @@ function handleMouseMove(e: MouseEvent) {
}
}
function onWindowResize() {
function onWindowResize(e?: any): void {
viewportWidth.value = window.innerWidth
onResize(currentSidebarSize.value)
// if user hide sidebar and refresh the page then sidebar will be visible again so we have to set sidebar width
if (!e && isLeftSidebarOpen.value && !sideBarSize.value.current && !isMobileMode.value) {
currentSidebarSize.value = sideBarSize.value.old
}
leftSidebarWidthPercent.value = (currentSidebarSize.value / viewportWidth.value) * 100
// if sidebar width is greater than normalized width and this function is called from window resize event (not from template) update left sidebar width
if (e && normalizedWidth.value < sidebarWidth.value) {
onResize(leftSidebarWidthPercent.value)
}
}
onMounted(() => {
@ -125,7 +147,7 @@ onMounted(() => {
handleSidebarOpenOnMobileForNonViews()
})
const remToPx = (rem: number) => {
function remToPx(rem: number) {
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
return rem * fontSize
}
@ -138,10 +160,9 @@ function onResize(widthPercent: any) {
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
// If the viewport width is less than 1560px, the max sidebar width should be 20rem
if (viewportWidth.value <= 1560) {
if (width > remToPx(20)) {
sideBarSize.value.old = ((20 * fontSize) / viewportWidth.value) * 100
sideBarSize.value.old = 20 * fontSize
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
}
@ -150,31 +171,19 @@ function onResize(widthPercent: any) {
const widthRem = width / fontSize
if (widthRem < 16) {
sideBarSize.value.old = ((16 * fontSize) / viewportWidth.value) * 100
sideBarSize.value.old = 16 * fontSize
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
} else if (widthRem > 35) {
sideBarSize.value.old = ((35 * fontSize) / viewportWidth.value) * 100
sideBarSize.value.old = 35 * fontSize
if (isLeftSidebarOpen.value) sideBarSize.value.current = sideBarSize.value.old
return
}
sideBarSize.value.old = widthPercent
sideBarSize.value.old = width
sideBarSize.value.current = sideBarSize.value.old
}
const normalizedWidth = computed(() => {
const maxSize = remToPx(35)
const minSize = remToPx(16)
if (sidebarWidth.value > maxSize) {
return maxSize
} else if (sidebarWidth.value < minSize) {
return minSize
} else {
return sidebarWidth.value
}
})
</script>
<template>
@ -192,7 +201,8 @@ const normalizedWidth = computed(() => {
max-size="60%"
class="nc-sidebar-splitpane !sm:max-w-140 relative !overflow-visible flex"
:style="{
width: `${mobileNormalizedSidebarSize}%`,
'width': `${mobileNormalizedSidebarSize}%`,
'min-width': `${mobileNormalizedSidebarSize}%`,
}"
>
<div
@ -215,7 +225,7 @@ const normalizedWidth = computed(() => {
:size="mobileNormalizedContentSize"
class="flex-grow"
:style="{
'min-width': `${100 - mobileNormalizedSidebarSize}%`,
'min-width': `${mobileNormalizedContentSize}%`,
}"
>
<slot name="content" />

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

@ -57,6 +57,13 @@ async function updateIfSourceOrderIsNullOrDuplicate() {
if (!hasNullOrDuplicates) return
// make sure default source is always first
sources.value = sources.value.sort((a, b) => {
if (a.is_local || a.is_meta) return -1
if (b.is_local || b.is_meta) return 1
return (a.order ?? 0) - (b.order ?? 0)
})
// update the local state
sources.value = sources.value.map((source, i) => {
return {
@ -75,6 +82,7 @@ async function updateIfSourceOrderIsNullOrDuplicate() {
})
}),
)
await loadProject(base.value.id as string, true)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}

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

@ -31,12 +31,6 @@ const tables = ref<any[]>([])
const searchInput = ref('')
const selectAll = ref({
editor: false,
commenter: false,
viewer: false,
})
const filteredTables = computed(() =>
tables.value.filter(
(el) =>
@ -46,6 +40,24 @@ const filteredTables = computed(() =>
),
)
const allSelected = computed(() => {
return roles.value.reduce((acc, role) => {
return {
...acc,
[role]: tables.value.filter((t) => t.disabled[role]).length === 0,
}
}, {} as Record<Role, boolean>)
})
const toggleSelectAll = (role: Role) => {
const newValue = !allSelected.value[role]
tables.value.forEach((t) => {
t.disabled[role] = newValue
t.edited = true
})
}
async function loadTableList() {
try {
if (!baseId.value) return
@ -81,62 +93,44 @@ async function saveUIAcl() {
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)
const columns = [
{
title: tableHeaderRenderer(t('labels.tableName')),
title: t('labels.tableName'),
name: 'Table Name',
},
{
title: tableHeaderRenderer(t('labels.viewName')),
title: t('labels.viewName'),
name: 'View Name',
},
{
title: tableHeaderRenderer(t('objects.roleType.editor')),
title: t('objects.roleType.editor'),
name: 'editor',
width: 120,
},
{
title: tableHeaderRenderer(t('objects.roleType.commenter')),
title: t('objects.roleType.commenter'),
name: 'commenter',
width: 120,
},
{
title: tableHeaderRenderer(t('objects.roleType.viewer')),
title: t('objects.roleType.viewer'),
name: 'viewer',
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>
<div class="h-full flex flex-row w-full items-center justify-center">
<div class="h-full flex flex-col">
<div class="w-full h-full flex flex-col">
<NcTooltip class="mb-4 first-letter:capital font-bold max-w-100 truncate" show-on-truncate-only>
<template #title>{{ base.title }}</template>
<span> UI ACL : {{ base.title }} </span>
@ -165,91 +159,121 @@ const toggleSelectAll = (role: Role) => {
</div>
<div class="h-auto max-h-[calc(100%_-_102px)] overflow-y-auto nc-scrollbar-thin">
<a-table
class="w-full"
size="small"
:data-source="filteredTables"
:columns="columns"
:pagination="false"
:loading="isLoading"
sticky
bordered
:custom-row="
(record) => ({
class: `nc-acl-table-row nc-acl-table-row-${record.title}`,
})
"
>
<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 class="w-full" size="small">
<div class="table-header">
<template v-for="column in columns" :key="column.name">
<template v-if="['editor', 'commenter', 'viewer'].includes(column.name)">
<div class="table-header-col" :style="`width: ${column.width}px`">
<div class="flex flex-row gap-x-1">
<NcCheckbox
v-model:checked="allSelected[column.name as Role]"
@change="toggleSelectAll(column.name as Role)"
/>
<div class="flex capitalize">
{{ column.name }}
</div>
</div>
</div>
</template>
<template v-else>
<div class="table-header-col flex-1">
<div class="flex capitalize">{{ column.title }}</div>
</div>
</div>
</template>
</template>
<template v-else>{{ column.name }}</template>
</template>
<template #emptyText>
</div>
<template v-if="filteredTables.length === 0">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
<template #bodyCell="{ record, column }">
<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" />
</div>
<NcTooltip class="overflow-ellipsis min-w-0 shrink-1 truncate" show-on-truncate-only>
<template #title>{{ record._ptn }}</template>
<span>{{ record._ptn }}</span>
</NcTooltip>
</div>
</div>
<div v-if="column.name === 'View Name'">
<div class="flex items-center gap-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon
v-if="record?.meta?.icon"
:meta="{ meta: record.meta, type: 'view' }"
class="text-gray-500 !text-sm children:(!w-5 !h-5)"
/>
<GeneralViewIcon v-else :meta="record" class="text-gray-500"></GeneralViewIcon>
</div>
<NcTooltip class="overflow-ellipsis min-w-0 shrink-1 truncate" show-on-truncate-only>
<template #title>{{ record.is_default ? $t('title.defaultView') : record.title }}</template>
<span>{{ record.is_default ? $t('title.defaultView') : record.title }}</span>
</NcTooltip>
</div>
</div>
<div v-for="role in roles" :key="role">
<div v-if="column.name === role">
<NcTooltip>
<template #title>
<span v-if="record.disabled[role]">
{{ $t('labels.clickToMake') }} '{{ record.title }}' {{ $t('labels.visibleForRole') }} {{ role }}
{{ $t('labels.inUI') }} dashboard</span
>
<span v-else
>{{ $t('labels.clickToHide') }} '{{ record.title }}' {{ $t('labels.forRole') }}:{{ role }}
{{ $t('labels.inUI') }}</span
>
</template>
<NcCheckbox
:checked="!record.disabled[role]"
:class="`nc-acl-${record.title}-${role}-chkbox !ml-0.25`"
@change="onRoleCheck(record, role as Role)"
/>
</NcTooltip>
</div>
<template v-else>
<div
v-for="record in filteredTables"
:key="record.id"
:class="`table-body-row nc-acl-table-row nc-acl-table-row-${record.title}`"
>
<template v-for="column in columns" :key="column.name">
<template v-if="column.name === 'Table Name'">
<div class="table-body-row-col flex-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon :meta="{ meta: record.table_meta, type: record.ptype }" class="text-gray-500" />
</div>
<NcTooltip class="overflow-ellipsis min-w-0 shrink-1 truncate" show-on-truncate-only>
<template #title>{{ record._ptn }}</template>
<span>{{ record._ptn }}</span>
</NcTooltip>
</div>
</template>
<template v-else-if="column.name === 'View Name'">
<div class="table-body-row-col flex-1">
<div class="min-w-5 flex items-center justify-center">
<GeneralTableIcon
v-if="record?.meta?.icon"
:meta="{ meta: record.meta, type: 'view' }"
class="text-gray-500 !text-sm children:(!w-5 !h-5)"
/>
<GeneralViewIcon v-else :meta="record" class="text-gray-500"></GeneralViewIcon>
</div>
<NcTooltip class="overflow-ellipsis min-w-0 shrink-1 truncate" show-on-truncate-only>
<template #title>{{ record.is_default ? $t('title.defaultView') : record.title }}</template>
<span>{{ record.is_default ? $t('title.defaultView') : record.title }}</span>
</NcTooltip>
</div>
</template>
<template v-else>
<div class="table-body-row-col" :style="`width: ${column.width}px`">
<NcTooltip>
<template #title>
<span v-if="record.disabled[column.name]">
{{ $t('labels.clickToMake') }} '{{ record.title }}' {{ $t('labels.visibleForRole') }} {{ column.name }}
{{ $t('labels.inUI') }} dashboard</span
>
<span v-else
>{{ $t('labels.clickToHide') }} '{{ record.title }}' {{ $t('labels.forRole') }}:{{ column.name }}
{{ $t('labels.inUI') }}</span
>
</template>
<NcCheckbox
:checked="!record.disabled[column.name]"
:class="`nc-acl-${record.title}-${column.name}-chkbox !ml-0.25`"
@change="onRoleCheck(record, column.name as Role)"
/>
</NcTooltip>
</div>
</template>
</template>
</div>
</template>
</a-table>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.table-header {
@apply flex items-center bg-gray-100 border-1 border-gray-200;
}
.table-header-col {
@apply flex items-center p-2 border-r-1 border-gray-200;
}
.table-header-col:last-child {
@apply border-r-0;
}
.table-body-row {
@apply flex items-center bg-white border-r-1 border-l-1 border-b-1 border-gray-200;
}
.table-body-row-col {
@apply flex items-center p-2 border-r-1 border-gray-200;
}
.table-body-row-col:last-child {
@apply border-r-0;
}
</style>

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

@ -149,7 +149,10 @@ async function listenForUpdates(id?: string) {
const job = id
? { id }
: jobs.find((j) => j.base_id === baseId && j.status !== JobStatus.COMPLETED && j.status !== JobStatus.FAILED)
: jobs
// sort by created_at desc (latest first)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.find((j) => j.base_id === baseId && j.status !== JobStatus.COMPLETED && j.status !== JobStatus.FAILED)
if (!job) {
listeningForUpdates.value = false

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

@ -70,6 +70,7 @@ const defaultImportState = {
autoSelectFieldTypes: true,
firstRowAsHeaders: true,
shouldImportData: true,
importDataOnly: true,
},
}
const importState = reactive(defaultImportState)
@ -84,7 +85,6 @@ const IsImportTypeExcel = computed(() => importType === 'excel')
const validators = computed(() => ({
url: [fieldRequiredValidator(), importUrlValidator, isImportTypeCsv.value ? importCsvUrlValidator : importExcelUrlValidator],
maxRowsToParse: [fieldRequiredValidator()],
}))
const { validate, validateInfos } = useForm(importState, validators)
@ -152,10 +152,6 @@ const disableImportButton = computed(() => !templateEditorRef.value?.isValid ||
const disableFormatJsonButton = computed(() => !jsonEditorRef.value?.isValid)
const modalWidth = computed(() => {
if (importType === 'excel' && templateEditorModal.value) {
return 'max(90vw, 600px)'
}
return 'max(60vw, 600px)'
})
@ -254,14 +250,15 @@ function formatJson() {
jsonEditorRef.value?.format()
}
function populateUniqueTableName(tn: string) {
function populateUniqueTableName(tn: string, draftTn: string[] = []) {
let c = 1
while (
draftTn.includes(tn) ||
baseTables.value.get(baseId)?.some((t: TableType) => {
const s = t.table_name.split('___')
let target = t.table_name
if (s.length > 1) target = s[1]
return target === `${tn}`
return target === `${tn}` || t.table_name === `${tn}`
})
) {
tn = `${tn}_${c++}`
@ -492,10 +489,13 @@ async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
if (importDataOnly) importColumns.value = templateGenerator!.getColumns()
else {
// ensure the target table name not exist in current table list
templateData.value.tables = templateData.value.tables.map((table: Record<string, any>) => ({
...table,
table_name: populateUniqueTableName(table.table_name),
}))
const draftTableNames = [] as string[]
templateData.value.tables = templateData.value.tables.map((table: Record<string, any>) => {
const table_name = populateUniqueTableName(table.table_name, draftTableNames)
draftTableNames.push(table_name)
return { ...table, table_name }
})
}
importData.value = templateGenerator!.getData()
}
@ -517,6 +517,11 @@ const onError = () => {
const onChange = () => {
isError.value = false
}
onMounted(() => {
importState.parserConfig.importDataOnly = importDataOnly
importState.parserConfig.autoSelectFieldTypes = importDataOnly
})
</script>
<template>
@ -531,7 +536,12 @@ const onChange = () => {
<div class="px-5">
<div class="prose-xl font-weight-bold my-5">{{ importMeta.header }}</div>
<div class="mt-5">
<div
class="mt-5"
:class="{
'mb-4': templateEditorModal,
}"
>
<LazyTemplateEditor
v-if="templateEditorModal"
ref="templateEditorRef"
@ -582,6 +592,9 @@ const onChange = () => {
<p class="ant-upload-hint">
{{ importMeta.uploadHint }}
</p>
<template #removeIcon>
<component :is="iconMap.deleteListItem" />
</template>
</a-upload-dragger>
</div>
</a-tab-pane>
@ -608,9 +621,9 @@ const onChange = () => {
</template>
<div class="pr-10 pt-5">
<a-form :model="importState" name="quick-import-url-form" layout="vertical" class="mb-0">
<a-form :model="importState" name="quick-import-url-form" layout="vertical" class="mb-0 !ml-0.5">
<a-form-item :label="importMeta.urlInputLabel" v-bind="validateInfos.url">
<a-input v-model:value="importState.url" size="large" />
<a-input v-model:value="importState.url" size="large" class="!rounded-md" />
</a-form-item>
</a-form>
</div>
@ -625,16 +638,6 @@ const onChange = () => {
<!-- Advanced Settings -->
<span class="prose-lg">{{ $t('title.advancedSettings') }}</span>
<a-form-item class="!my-2" :label="t('msg.info.footMsg')" v-bind="validateInfos.maxRowsToParse">
<a-input-number v-model:value="importState.parserConfig.maxRowsToParse" :min="1" :max="50000" />
</a-form-item>
<a-form-item v-if="!importDataOnly" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.autoSelectFieldTypes">
<span class="caption">{{ $t('labels.autoSelectFieldTypes') }}</span>
</a-checkbox>
</a-form-item>
<a-form-item v-if="isImportTypeCsv || IsImportTypeExcel" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.firstRowAsHeaders">
<span class="caption">{{ $t('labels.firstRowAsHeaders') }}</span>
@ -699,3 +702,12 @@ const onChange = () => {
</template>
</a-modal>
</template>
<style lang="scss" scoped>
:deep(.ant-upload-list-item-thumbnail) {
line-height: 48px;
}
:deep(.ant-upload-list-item-card-actions-btn.ant-btn-icon-only) {
@apply !h-6;
}
</style>

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

@ -35,56 +35,61 @@ const activeExtension = computed(() => {
:class="{ active: vModel }"
:closable="from === 'extension'"
:footer="null"
:width="1280"
:width="1154"
size="medium"
wrap-class-name="nc-modal-extension-market"
>
<div v-if="activeExtension" class="flex flex-col w-full h-full">
<div v-if="from === 'market'" class="h-[40px] flex items-start">
<div class="flex items-center gap-2 pr-2 pb-2 cursor-pointer hover:text-primary" @click="onBack">
<GeneralIcon icon="ncArrowLeft" />
<span>Back</span>
</div>
<div v-if="from === 'market'" class="flex-none h-8 flex items-center mb-4">
<NcButton size="xsmall" type="text" class="!bg-gray-200/75 !hover:bg-gray-200 !rounded-full" @click="onBack">
<div class="flex items-center gap-2 px-2">
<GeneralIcon icon="ncArrowLeft" />
<span>Back</span>
</div>
</NcButton>
</div>
<div v-else class="h-[40px]"></div>
<div v-else class="h-8"></div>
<div class="extension-details">
<div class="extension-details-left">
<div class="flex">
<img :src="getExtensionIcon(activeExtension.iconUrl)" alt="icon" class="h-[90px]" />
<div class="flex flex-col p-4">
<div class="extension-details-left nc-scrollbar-thin">
<div class="flex gap-6">
<img :src="getExtensionIcon(activeExtension.iconUrl)" alt="icon" class="h-[80px] w-[80px] object-contain" />
<div class="flex flex-col gap-3">
<div class="font-weight-700 text-2xl">{{ activeExtension.title }}</div>
</div>
</div>
<div class="p-4">
<div class="whitespace-pre-line">{{ activeExtension.description }}</div>
</div>
<div class="whitespace-pre-line text-base text-gray-600">{{ activeExtension.description }}</div>
</div>
<div class="extension-details-right">
<NcButton class="w-full" @click="onAddExtension(activeExtension)">
<div class="flex items-center justify-center">Add Extension</div>
</NcButton>
<div class="flex flex-col gap-1">
<div class="text-md font-weight-600">Version</div>
<div>{{ activeExtension.version }}</div>
</div>
<div class="flex flex-col gap-1">
<div v-if="activeExtension.publisherName" class="text-md font-weight-600">Publisher</div>
<div>{{ activeExtension.publisherName }}</div>
</div>
<div v-if="activeExtension.publisherEmail" class="flex flex-col gap-1">
<div class="text-md font-weight-600">Publisher Email</div>
<div>
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherEmail }}
</a>
<div class="flex flex-col gap-4 nc-scrollbar-thin">
<div class="flex flex-col gap-1">
<div class="extension-details-right-title">Version</div>
<div class="extension-details-right-subtitle">{{ activeExtension.version }}</div>
</div>
</div>
<div v-if="activeExtension.publisherUrl" class="flex flex-col gap-1">
<div class="text-md font-weight-600">Publisher Website</div>
<div>
<a :href="activeExtension.publisherUrl" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherUrl }}
</a>
<div class="flex flex-col gap-1">
<div v-if="activeExtension.publisherName" class="extension-details-right-title">Publisher</div>
<div class="extension-details-right-subtitle">{{ activeExtension.publisherName }}</div>
</div>
<div v-if="activeExtension.publisherEmail" class="flex flex-col gap-1">
<div class="extension-details-right-title">Publisher Email</div>
<div class="extension-details-right-subtitle">
<a :href="`mailto:${activeExtension.publisherEmail}`" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherEmail }}
</a>
</div>
</div>
<div v-if="activeExtension.publisherUrl" class="flex flex-col gap-1">
<div class="extension-details-right-title">Publisher Website</div>
<div class="extension-details-right-subtitle">
<a :href="activeExtension.publisherUrl" target="_blank" rel="noopener noreferrer">
{{ activeExtension.publisherUrl }}
</a>
</div>
</div>
</div>
</div>
@ -95,14 +100,21 @@ const activeExtension = computed(() => {
<style lang="scss" scoped>
.extension-details {
@apply flex w-full h-full;
@apply flex w-full h-full gap-8 px-3;
.extension-details-left {
@apply flex flex-col w-3/4 p-2;
@apply flex flex-col gap-6 w-3/4;
}
.extension-details-right {
@apply w-1/4 p-2 flex flex-col gap-4;
@apply w-1/4 flex flex-col gap-4;
.extension-details-right-title {
@apply text-base font-weight-700 text-gray-800;
}
.extension-details-right-subtitle {
@apply text-sm font-weight-500 text-gray-600;
}
}
}
</style>

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

@ -6,13 +6,24 @@ interface Prop {
const { extensionId, error } = defineProps<Prop>()
const { extensionList, extensionsLoaded, availableExtensions, getExtensionIcon, duplicateExtension, showExtensionDetails } =
useExtensions()
const {
extensionList,
extensionsLoaded,
availableExtensions,
eventBus,
getExtensionIcon,
duplicateExtension,
showExtensionDetails,
} = useExtensions()
const activeError = ref(error)
const extensionRef = ref<HTMLElement>()
const extensionModalRef = ref<HTMLElement>()
const isMouseDown = ref(false)
const extension = computed(() => {
const ext = extensionList.value.find((ext) => ext.id === extensionId)
if (!ext) {
@ -33,7 +44,6 @@ const enableEditMode = () => {
nextTick(() => {
titleInput.value?.focus()
titleInput.value?.select()
titleInput.value?.scrollIntoView()
})
}
@ -48,6 +58,17 @@ const component = ref<any>(null)
const extensionManifest = ref<any>(null)
const extensionMinHeight = computed(() => {
switch (extension.value.extensionId) {
case 'nc-data-exporter':
return 'min-h-[300px] h-[300px]'
case 'nc-json-exporter':
return 'min-h-[194px] h-[194px]'
case 'nc-csv-import':
return 'min-h-[180px] h-[180px]'
}
})
onMounted(() => {
until(extensionsLoaded)
.toMatch((v) => v)
@ -84,66 +105,91 @@ const closeFullscreen = (e: MouseEvent) => {
fullscreen.value = false
}
}
const handleDuplicateExtension = async (id: string, open: boolean = false) => {
const duplicatedExt = await duplicateExtension(id)
if (duplicatedExt?.id && open) {
fullscreen.value = false
eventBus.emit(ExtensionsEvents.DUPLICATE, duplicatedExt.id)
}
}
// #Listeners
eventBus.on((event, payload) => {
if (event === ExtensionsEvents.DUPLICATE && extension.value.id === payload) {
setTimeout(() => {
nextTick(() => {
extensionRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, 500)
}
})
</script>
<template>
<div class="w-full p-2">
<div class="extension-wrapper">
<div class="extension-header">
<div class="extension-header-left">
<GeneralIcon icon="drag" />
<img v-if="extensionManifest" :src="getExtensionIcon(extensionManifest.iconUrl)" alt="icon" class="h-6" />
<div ref="extensionRef" class="w-full px-4" :data-testid="extension.id">
<div
class="extension-wrapper"
:class="[
`${!collapsed ? extensionMinHeight : ''}`,
{
'!h-auto': collapsed,
'isOpen': !collapsed,
'mousedown': isMouseDown,
},
]"
@mousedown="isMouseDown = true"
@mouseup="isMouseDown = false"
>
<div class="extension-header" :class="{ 'mb-2': !collapsed }">
<div class="extension-header-left max-w-[calc(100%_-_100px)]">
<!-- Todo: enable later when we support extension reordering -->
<!-- eslint-disable vue/no-constant-condition -->
<NcButton v-if="false" size="xxsmall" type="text">
<GeneralIcon icon="ncDrag" class="flex-none text-gray-500" />
</NcButton>
<img
v-if="extensionManifest"
:src="getExtensionIcon(extensionManifest.iconUrl)"
alt="icon"
class="h-6 w-6 object-contain"
/>
<input
v-if="titleEditMode"
v-if="titleEditMode && !fullscreen"
ref="titleInput"
v-model="tempTitle"
class="flex-grow leading-1 outline-0 ring-none capitalize !text-inherit !bg-transparent w-4/5"
class="flex-grow leading-1 outline-0 ring-none !text-inherit !bg-transparent w-4/5 extension-title"
@click.stop
@keyup.enter="updateExtensionTitle"
@keyup.esc="updateExtensionTitle"
@blur="updateExtensionTitle"
/>
<div v-else class="extension-title" @dblclick="enableEditMode">{{ extension.title }}</div>
<NcTooltip v-else show-on-truncate-only class="truncate">
<template #title>
{{ extension.title }}
</template>
<span class="extension-title" @dblclick="enableEditMode">
{{ extension.title }}
</span>
</NcTooltip>
</div>
<div class="extension-header-right">
<GeneralIcon v-if="!activeError" icon="expand" @click="fullscreen = true" />
<NcDropdown :trigger="['click']">
<GeneralIcon icon="threeDotVertical" />
<template #overlay>
<NcMenu>
<template v-if="!activeError">
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="enableEditMode">
<GeneralIcon icon="edit" />
Rename
</NcMenuItem>
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="duplicateExtension(extension.id)">
<GeneralIcon icon="duplicate" />
Duplicate
</NcMenuItem>
<NcMenuItem
data-rec="true"
class="!hover:text-primary"
@click="showExtensionDetails(extension.extensionId, 'extension')"
>
<GeneralIcon icon="info" />
Details
</NcMenuItem>
<NcDivider />
</template>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="extension.clear()">
<GeneralIcon icon="reload" />
Clear Data
</NcMenuItem>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="extension.delete()">
<GeneralIcon icon="delete" />
Delete
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<GeneralIcon v-if="collapsed" icon="arrowUp" @click="collapsed = !collapsed" />
<GeneralIcon v-else icon="arrowDown" @click="collapsed = !collapsed" />
<NcButton v-if="!activeError" type="text" size="xxsmall" @click="fullscreen = true">
<GeneralIcon icon="expand" />
</NcButton>
<ExtensionsExtensionMenu
:active-error="activeError"
@rename="enableEditMode"
@duplicate="handleDuplicateExtension(extension.id, true)"
@show-details="showExtensionDetails(extension.extensionId, 'extension')"
@clear-data="extension.clear()"
@delete="extension.delete()"
/>
<NcButton size="xxsmall" type="text" @click="collapsed = !collapsed">
<GeneralIcon :icon="collapsed ? 'arrowUp' : 'arrowDown'" class="flex-none" />
</NcButton>
</div>
</div>
<template v-if="activeError">
@ -169,24 +215,60 @@ const closeFullscreen = (e: MouseEvent) => {
</template>
<template v-else>
<Teleport to="body" :disabled="!fullscreen">
<div ref="extensionModalRef" :class="{ 'extension-modal': fullscreen }" @click="closeFullscreen">
<div :class="{ 'extension-modal-content': fullscreen }">
<div
v-if="fullscreen"
class="flex items-center justify-between p-2 bg-gray-100 rounded-t-lg cursor-default h-[40px]"
>
<div class="flex items-center gap-2 text-gray-500 font-weight-600">
<img v-if="extensionManifest" :src="getExtensionIcon(extensionManifest.iconUrl)" alt="icon" class="w-6 h-6" />
<div class="text-sm">{{ extension.title }}</div>
<div
ref="extensionModalRef"
:class="{ 'extension-modal': fullscreen, 'h-[calc(100%_-_32px)]': !fullscreen }"
@click="closeFullscreen"
>
<div :class="{ 'extension-modal-content': fullscreen, 'h-full': !fullscreen }">
<div v-if="fullscreen" class="flex items-center justify-between cursor-default">
<div class="flex-1 max-w-[calc(100%_-_96px)] flex items-center gap-2 text-gray-800 font-weight-600">
<img
v-if="extensionManifest"
:src="getExtensionIcon(extensionManifest.iconUrl)"
alt="icon"
class="flex-none w-6 h-6"
/>
<input
v-if="titleEditMode"
ref="titleInput"
v-model="tempTitle"
class="flex-grow leading-1 outline-0 ring-none !text-xl !bg-transparent !font-weight-600"
@click.stop
@keyup.enter="updateExtensionTitle"
@keyup.esc="updateExtensionTitle"
@blur="updateExtensionTitle"
/>
<NcTooltip v-else show-on-truncate-only class="extension-title truncate text-xl">
<template #title>
{{ extension.title }}
</template>
<span @dblclick="enableEditMode">
{{ extension.title }}
</span>
</NcTooltip>
</div>
<div class="flex items-center gap-4">
<ExtensionsExtensionMenu
:active-error="activeError"
:fullscreen="fullscreen"
@rename="enableEditMode"
@duplicate="handleDuplicateExtension(extension.id, true)"
@show-details="showExtensionDetails(extension.extensionId, 'extension')"
@clear-data="extension.clear()"
@delete="extension.delete()"
/>
<NcButton size="small" type="text" class="flex-none" @click="fullscreen = false">
<GeneralIcon icon="close" />
</NcButton>
</div>
<GeneralIcon class="cursor-pointer" icon="close" @click="fullscreen = false" />
</div>
<div
v-show="fullscreen || !collapsed"
class="extension-content"
:class="{ 'border-1': !fullscreen, 'h-[calc(100%-40px)]': fullscreen }"
:class="{ 'h-[calc(100%-40px)]': fullscreen, 'h-full': !fullscreen }"
>
<component :is="component" :key="extension.uiKey" />
<component :is="component" :key="extension.uiKey" class="h-full" />
</div>
</div>
</div>
@ -198,18 +280,27 @@ const closeFullscreen = (e: MouseEvent) => {
<style scoped lang="scss">
.extension-wrapper {
@apply bg-white rounded-lg p-2 w-full border-1;
@apply bg-white rounded-xl px-3 py-[11px] w-full border-1 relative;
&.isOpen {
resize: vertical;
&:hover,
&.mousedown {
overflow-y: auto;
}
}
}
.extension-header {
@apply flex justify-between mb-2;
@apply flex justify-between;
.extension-header-left {
@apply flex items-center gap-2;
@apply flex-1 flex items-center gap-2;
}
.extension-header-right {
@apply flex items-center gap-4;
@apply flex items-center gap-2;
}
.extension-title {
@ -222,10 +313,10 @@ const closeFullscreen = (e: MouseEvent) => {
}
.extension-modal {
@apply absolute top-0 left-0 z-50 w-full h-full bg-black bg-opacity-50;
@apply absolute top-0 left-0 z-1000 w-full h-full bg-black bg-opacity-50;
.extension-modal-content {
@apply bg-white rounded-lg w-[90%] h-[90vh] mt-[5vh] mx-auto;
@apply bg-white rounded-2xl w-[90%] max-w-[1154px] h-[90vh] mt-[5vh] mx-auto p-6 flex flex-col gap-3;
}
}
</style>

49
packages/nc-gui/components/extensions/ExtensionMenu.vue

@ -0,0 +1,49 @@
<script setup lang="ts">
interface Props {
fullscreen?: boolean
activeError?: boolean
}
const { fullscreen, activeError } = defineProps<Props>()
const emits = defineEmits(['rename', 'duplicate', 'showDetails', 'clearData', 'delete'])
</script>
<template>
<div class="flex items-center">
<NcDropdown :trigger="['click']">
<NcButton type="text" :size="fullscreen ? 'small' : 'xxsmall'">
<GeneralIcon icon="threeDotVertical" />
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="!activeError">
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="emits('rename')">
<GeneralIcon icon="edit" />
Rename
</NcMenuItem>
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="emits('duplicate')">
<GeneralIcon icon="duplicate" />
Duplicate
</NcMenuItem>
<NcMenuItem data-rec="true" class="!hover:text-primary" @click="emits('showDetails')">
<GeneralIcon icon="info" />
Details
</NcMenuItem>
<NcDivider />
</template>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="emits('clearData')">
<GeneralIcon icon="reload" />
Clear Data
</NcMenuItem>
<NcMenuItem data-rec="true" class="!text-red-500 !hover:bg-red-50" @click="emits('delete')">
<GeneralIcon icon="delete" />
Delete
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</div>
</template>
<style scoped lang="scss"></style>

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

@ -11,6 +11,16 @@ const vModel = useVModel(props, 'modelValue', emit)
const { availableExtensions, addExtension, getExtensionIcon, showExtensionDetails } = useExtensions()
const searchQuery = ref<string>('')
const filteredAvailableExtensions = computed(() =>
(availableExtensions.value || []).filter(
(ext) =>
ext.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
ext.description.toLowerCase().includes(searchQuery.value.toLowerCase()),
),
)
const onExtensionClick = (extensionId: string) => {
showExtensionDetails(extensionId)
vModel.value = false
@ -29,38 +39,70 @@ const onAddExtension = (ext: any) => {
:class="{ active: vModel }"
:closable="true"
:footer="null"
:width="1280"
:width="1154"
size="medium"
wrap-class-name="nc-modal-extension-market"
>
<div class="flex flex-col h-full">
<div class="flex items-center px-4 py-2">
<div class="flex items-center gap-2">
<GeneralIcon icon="puzzle" />
<div class="font-weight-700">Extensions Marketplace</div>
</div>
<template #header>
<div class="flex items-center gap-2 pb-2">
<GeneralIcon icon="puzzle" class="h-5 w-5 flex-none" />
<div class="font-weight-700 text-base">Extensions Marketplace</div>
</div>
<div class="flex flex-col flex-1 px-4 py-2">
<div class="flex flex-wrap gap-4 p-2">
<template v-for="ext of availableExtensions" :key="ext.id">
<div class="flex border-1 rounded-lg p-2 w-[360px] cursor-pointer" @click="onExtensionClick(ext.id)">
<div class="h-[60px] overflow-hidden m-auto">
<img :src="getExtensionIcon(ext.iconUrl)" alt="icon" class="w-full h-full object-cover" />
</template>
<div class="flex flex-col h-[calc(100%_-_41px)]">
<div class="h-full flex flex-col gap-4 flex-1 pt-2">
<div class="flex flex max-w-[470px]">
<a-input
v-model:value="searchQuery"
type="text"
class="!h-10 !px-3 !py-1 !rounded-lg"
placeholder="Search for an extension..."
allow-clear
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
<div
class="max-h-[calc(100%_-_40px)] flex flex-wrap gap-3 nc-scrollbar-thin"
:class="{
'h-full': searchQuery && !filteredAvailableExtensions.length && availableExtensions.length,
}"
>
<template v-for="ext of filteredAvailableExtensions" :key="ext.id">
<div class="flex border-1 rounded-xl p-3 w-[360px] cursor-pointer" @click="onExtensionClick(ext.id)">
<div class="h-[60px] w-[60px] overflow-hidden m-auto">
<img :src="getExtensionIcon(ext.iconUrl)" alt="icon" class="w-full h-full object-contain" />
</div>
<div class="flex flex-grow flex-col ml-3">
<div class="flex justify-between">
<div class="flex flex-grow flex-col gap-2 ml-3">
<div class="flex justify-between gap-1">
<div class="font-weight-600">{{ ext.title }}</div>
<NcButton size="xsmall" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-1 mx-1">
<NcButton size="xsmall" type="secondary" @click.stop="onAddExtension(ext)">
<div class="flex items-center gap-2 mx-1">
<GeneralIcon icon="plus" />
Add
</div>
</NcButton>
</div>
<div class="w-[250px] h-[50px] text-xs line-clamp-3">{{ ext.description }}</div>
<div class="w-[250px] h-[32px] text-xs text-gray-500 line-clamp-2">{{ ext.description }}</div>
</div>
</div>
</template>
<div
v-if="searchQuery && !filteredAvailableExtensions.length && availableExtensions.length"
class="w-full h-full flex items-center justify-center"
>
<div class="pb-6 text-gray-500 flex flex-col items-center gap-6 text-center">
<img
src="~assets/img/placeholder/no-search-result-found.png"
class="!w-[164px] flex-none"
alt="No search results found"
/>
{{ $t('title.noResultsMatchedYourSearch') }}
</div>
</div>
</div>
</div>
</div>

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

@ -2,23 +2,88 @@
import { Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
const { extensionList, isPanelExpanded, isDetailsVisible, detailsExtensionId, detailsFrom, isMarketVisible, extensionPanelSize } =
useExtensions()
const {
extensionList,
isPanelExpanded,
isDetailsVisible,
detailsExtensionId,
detailsFrom,
isMarketVisible,
extensionPanelSize,
toggleExtensionPanel,
} = useExtensions()
const isReady = ref(false)
const searchQuery = ref<string>('')
const filteredExtensionList = computed(() =>
(extensionList.value || []).filter((ext) => ext.title.toLowerCase().includes(searchQuery.value.toLowerCase())),
)
const toggleMarket = () => {
isMarketVisible.value = !isMarketVisible.value
}
const normalizePaneMaxWidth = computed(() => {
if (isReady.value) {
return 60
} else {
return extensionPanelSize.value
}
})
defineExpose({
onReady: () => {
isReady.value = true
},
})
watch(isPanelExpanded, (newValue) => {
if (newValue && !isReady.value) {
setTimeout(() => {
isReady.value = true
}, 300)
}
})
</script>
<template>
<Pane v-if="isPanelExpanded" :size="extensionPanelSize" class="flex flex-col bg-orange-50">
<div class="flex items-center pl-3 pt-3 font-weight-800 text-orange-500">Extensions</div>
<Pane
v-if="isPanelExpanded"
:size="extensionPanelSize"
min-size="10%"
max-size="60%"
class="flex flex-col gap-3 bg-[#F0F3FF]"
:style="{
minWidth: isReady ? '300px' : `${normalizePaneMaxWidth}%`,
maxWidth: `${normalizePaneMaxWidth}%`,
}"
>
<div class="flex justify-between items-center px-4 pt-3">
<div class="flex items-center gap-3 font-weight-700 text-brand-500 text-base">
<GeneralIcon icon="puzzle" class="h-5 w-5" /> Extensions
</div>
<NcTooltip class="flex" hide-on-click placement="topRight">
<template #title> Hide extensions </template>
<NcButton
size="xxsmall"
type="text"
class="!text-gray-700 !hover:text-gray-800 !hover:bg-gray-200"
@click="toggleExtensionPanel"
>
<div class="flex items-center justify-center">
<GeneralIcon icon="doubleRightArrow" class="flex-none !text-gray-500/75" />
</div>
</NcButton>
</NcTooltip>
</div>
<template v-if="extensionList.length === 0">
<div class="flex items-center flex-col gap-2 w-full nc-scrollbar-md">
<div class="w-[100px] h-[100px] bg-gray-200 rounded-lg mt-[100px]"></div>
<div class="font-weight-700">No extensions added</div>
<div class="flex items-center flex-col gap-4 w-full nc-scrollbar-md text-center px-4">
<div class="w-[180px] h-[180px] bg-[#d9d9d9] rounded-3xl mt-[100px]"></div>
<div class="font-weight-700 text-base">No extensions added</div>
<div>Add Extensions from the community extensions marketplace</div>
<NcButton @click="toggleMarket">
<NcButton size="small" @click="toggleMarket">
<div class="flex items-center gap-2 font-weight-600">
<GeneralIcon icon="plus" />
Add Extension
@ -27,23 +92,57 @@ const toggleMarket = () => {
</div>
</template>
<template v-else>
<div class="flex w-full items-center justify-between py-2 px-2 bg-orange-50">
<div class="flex w-full items-center justify-between px-4">
<div class="flex flex-grow items-center mr-2">
<a-input type="text" class="!h-8 !px-3 !py-1 !rounded-lg" placeholder="Search Extension">
<a-input
v-model:value="searchQuery"
type="text"
class="!h-8 !px-3 !py-1 !rounded-lg"
placeholder="Search Extension"
allow-clear
>
<template #prefix>
<GeneralIcon icon="search" class="mr-2 h-4 w-4 text-gray-500 group-hover:text-black" />
</template>
</a-input>
</div>
<NcButton type="ghost" size="small" class="!text-primary !bg-white" @click="toggleMarket">
<div class="flex items-center gap-1 px-1 text-xs">
<NcButton type="ghost" size="small" class="!text-primary !bg-white children:children:max-w-full" @click="toggleMarket">
<div class="flex items-center gap-1 text-xs max-w-full">
<GeneralIcon icon="plus" />
Add Extension
<NcTooltip
class="max-w-[calc(100%_-_16px)] truncate"
show-on-truncate-only
overlay-class-name="children:-ml-2"
modifier-key=""
>
<template #title> Add Extension </template>
Add Extension
</NcTooltip>
</div>
</NcButton>
</div>
<div class="flex items-center flex-col w-full nc-scrollbar-md">
<ExtensionsWrapper v-for="ext in extensionList" :key="ext.id" :extension-id="ext.id" />
<div
class="nc-extension-list-wrapper flex items-center flex-col gap-3 w-full nc-scrollbar-md"
:class="{
'h-full': searchQuery && !filteredExtensionList.length && extensionList.length,
}"
>
<ExtensionsWrapper v-for="ext in filteredExtensionList" :key="ext.id" :extension-id="ext.id" />
<div
v-if="searchQuery && !filteredExtensionList.length && extensionList.length"
class="w-full h-full flex-1 flex items-center justify-center"
>
<div class="pb-6 text-gray-500 flex flex-col items-center gap-6 text-center">
<img
src="~assets/img/placeholder/no-search-result-found.png"
class="!w-[164px] flex-none"
alt="No search results found"
/>
{{ $t('title.noResultsMatchedYourSearch') }}
</div>
</div>
</div>
</template>
<ExtensionsMarket v-if="isMarketVisible" v-model="isMarketVisible" />
@ -56,4 +155,10 @@ const toggleMarket = () => {
</Pane>
</template>
<style lang="scss"></style>
<style lang="scss" scoped>
.nc-extension-list-wrapper {
&:last-child {
@apply pb-3;
}
}
</style>

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

@ -56,7 +56,13 @@ onKeyStroke('Enter', () => {
</div>
<slot name="entity-preview"></slot>
<template v-if="$slots.warning">
<a-alert type="warning" show-icon>
<template #message>
<slot name="warning"></slot>
</template>
</a-alert>
</template>
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end">
<NcButton type="secondary" size="small" @click="visible = false">
{{ $t('general.cancel') }}

5
packages/nc-gui/components/general/MaintenanceAlert.vue

@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template></template>
<style scoped lang="scss"></style>

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

@ -1,8 +1,8 @@
<script lang="ts" setup>
import type { TableType } from 'nocodb-sdk'
import type { ViewType } from 'nocodb-sdk'
const props = defineProps<{
meta: TableType
meta: ViewType
ignoreColor?: boolean
}>()

243
packages/nc-gui/components/nc/PaginationV2.vue

@ -99,136 +99,135 @@ const pageSizeOptions = [
<template>
<div class="nc-pagination flex flex-row items-center gap-x-0.25">
<template v-if="totalPages > 1">
<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 !border-0"
type="text"
size="xsmall"
:disabled="current === 1"
@click="goToFirstPage"
>
<GeneralIcon icon="doubleLeftArrow" class="nc-pagination-icon" />
<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 !border-0"
type="text"
size="xsmall"
:disabled="current === 1"
@click="goToFirstPage"
>
<GeneralIcon icon="doubleLeftArrow" class="nc-pagination-icon" />
</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 !border-0"
type="secondary"
size="xsmall"
:disabled="current === 1"
@click="changePage({ increase: false })"
>
<GeneralIcon icon="arrowLeft" class="nc-pagination-icon" />
</NcButton>
</component>
<div v-if="!isMobileMode" class="text-gray-500">
<NcDropdown placement="top" overlay-class-name="!shadow-none">
<NcButton class="!border-0 nc-select-page" type="secondary" size="xsmall">
<div class="flex gap-1 items-center px-2">
<span class="nc-current-page">
{{ current }}
</span>
<GeneralIcon icon="arrowDown" class="text-gray-800 mt-0.5 nc-select-expand-btn" />
</div>
</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 !border-0"
type="secondary"
size="xsmall"
:disabled="current === 1"
@click="changePage({ increase: false })"
>
<GeneralIcon icon="arrowLeft" class="nc-pagination-icon" />
</NcButton>
</component>
<div v-if="!isMobileMode" class="text-gray-500">
<NcDropdown placement="top" overlay-class-name="!shadow-none">
<NcButton class="!border-0 nc-select-page" type="secondary" size="xsmall">
<div class="flex gap-1 items-center px-2">
<span class="nc-current-page">
{{ current }}
</span>
<GeneralIcon icon="arrowDown" class="text-gray-800 mt-0.5 nc-select-expand-btn" />
</div>
</NcButton>
<template #overlay>
<NcMenu class="nc-pagination-menu overflow-hidden">
<NcSubMenu v-if="showSizeChanger" :key="`${localPageSize}page`" class="bg-gray-100 z-20 top-0 !sticky">
<template #title>
<div class="rounded-lg text-[13px] font-medium w-full">{{ localPageSize }} / page</div>
</template>
<NcMenuItem v-for="option in pageSizeOptions" :key="option.value" @click="localPageSize = option.value">
<span
class="text-[13px]"
<template #overlay>
<NcMenu class="nc-pagination-menu overflow-hidden">
<NcSubMenu v-if="showSizeChanger" :key="`${localPageSize}page`" class="bg-gray-100 z-20 top-0 !sticky">
<template #title>
<div class="rounded-lg text-[13px] font-medium w-full">{{ localPageSize }} / page</div>
</template>
<NcMenuItem v-for="option in pageSizeOptions" :key="option.value" @click="localPageSize = option.value">
<span
class="text-[13px]"
:class="{
'!text-brand-500': option.value === localPageSize,
}"
>
{{ option.value }} / page
</span>
</NcMenuItem>
</NcSubMenu>
<UseVirtualList
:key="localPageSize"
:list="pagesList"
height="auto"
:options="{ itemHeight: 36 }"
class="mt-1 max-h-46"
>
<template #default="{ data: item }">
<NcMenuItem
:key="`${localPageSize}${item.value}`"
:style="{
height: '36px',
}"
@click.stop="
changePage({
set: item.value,
})
"
>
<div
:class="{
'!text-brand-500': option.value === localPageSize,
'text-brand-500': item.value === current,
}"
class="flex text-[13px] !w-full text-gray-800 items-center justify-between"
>
{{ option.value }} / page
</span>
{{ item.label }}
</div>
</NcMenuItem>
</NcSubMenu>
<UseVirtualList
:key="localPageSize"
:list="pagesList"
height="auto"
:options="{ itemHeight: 36 }"
class="mt-1 max-h-46"
>
<template #default="{ data: item }">
<NcMenuItem
:key="`${localPageSize}${item.value}`"
:style="{
height: '36px',
}"
@click.stop="
changePage({
set: item.value,
})
"
>
<div
:class="{
'text-brand-500': item.value === current,
}"
class="flex text-[13px] !w-full text-gray-800 items-center justify-between"
>
{{ item.label }}
</div>
</NcMenuItem>
</template>
</UseVirtualList>
</NcMenu>
</template>
</NcDropdown>
</div>
<component :is="props.nextPageTooltip && mode === 'full' ? NcTooltip : 'div'">
<template v-if="props.nextPageTooltip" #title>
{{ props.nextPageTooltip }}
</template>
</UseVirtualList>
</NcMenu>
</template>
<NcButton
v-e="[`a:pagination:${entityName}:next-page`]"
class="next-page !border-0"
type="secondary"
size="xsmall"
:disabled="current === totalPages"
@click="changePage({ increase: true })"
>
<GeneralIcon icon="arrowRight" class="nc-pagination-icon" />
</NcButton>
</component>
</NcDropdown>
</div>
<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 !border-0"
type="secondary"
size="xsmall"
:disabled="current === totalPages"
@click="changePage({ increase: true })"
>
<GeneralIcon icon="arrowRight" class="nc-pagination-icon" />
</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 !border-0"
type="secondary"
size="xsmall"
:disabled="current === totalPages"
@click="goToLastPage"
>
<GeneralIcon icon="doubleRightArrow" class="nc-pagination-icon" />
</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 !border-0"
type="secondary"
size="xsmall"
:disabled="current === totalPages"
@click="goToLastPage"
>
<GeneralIcon icon="doubleRightArrow" class="nc-pagination-icon" />
</NcButton>
</component>
</template>
<div v-if="showSizeChanger && !isMobileMode" class="text-gray-500"></div>
</div>
</template>

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

@ -1,17 +1,25 @@
<script lang="ts" setup>
const props = defineProps<{
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
size?: 'small' | 'middle' | 'large'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
filterOption?: (input: string, option: any) => boolean
dropdownMatchSelectWidth?: boolean
allowClear?: boolean
loading?: boolean
}>()
import type { iconMap } from '#imports'
const props = withDefaults(
defineProps<{
value?: string | string[]
placeholder?: string
mode?: 'multiple' | 'tags'
size?: 'small' | 'middle' | 'large'
dropdownClassName?: string
showSearch?: boolean
// filterOptions is a function
filterOption?: (input: string, option: any) => boolean
dropdownMatchSelectWidth?: boolean
allowClear?: boolean
loading?: boolean
suffixIcon?: keyof typeof iconMap
}>(),
{
suffixIcon: 'arrowDown',
},
)
const emits = defineEmits(['update:value', 'change'])
@ -25,15 +33,7 @@ const dropdownClassName = computed(() => {
return className
})
const showSearch = computed(() => props.showSearch)
const filterOption = computed(() => props.filterOption)
const dropdownMatchSelectWidth = computed(() => props.dropdownMatchSelectWidth)
const loading = computed(() => props.loading)
const mode = computed(() => props.mode)
const { showSearch, filterOption, dropdownMatchSelectWidth, loading, mode } = toRefs(props)
const vModel = useVModel(props, 'value', emits)
@ -60,7 +60,7 @@ const onChange = (value: string) => {
>
<template #suffixIcon>
<GeneralLoader v-if="loading" />
<GeneralIcon v-else class="text-gray-800 nc-select-expand-btn" icon="arrowDown" />
<GeneralIcon v-else class="text-gray-800 nc-select-expand-btn" :icon="suffixIcon" />
</template>
<slot />
</a-select>

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

@ -51,7 +51,11 @@ const { currentRow } = useSmartsheetRowStoreOrThrow()
const { sqlUis } = storeToRefs(useBase())
const sqlUi = ref(column.value?.source_id ? sqlUis.value[column.value?.source_id] : Object.values(sqlUis.value)[0])
const sqlUi = ref(
column.value?.source_id && sqlUis.value[column.value?.source_id]
? sqlUis.value[column.value?.source_id]
: Object.values(sqlUis.value)[0],
)
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

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

@ -627,7 +627,7 @@ const handleSubmitRenameOrNewStack = async (loadMeta: boolean, stack?: any, stac
>
<div class="flex gap-2 items-center">
<component :is="iconMap.plus" class="flex-none w-4 h-4" />
{{ $t('activity.addNewRecord') }}
{{ $t('activity.newRecord') }}
</div>
</NcMenuItem>
<NcMenuItem
@ -1112,7 +1112,11 @@ const handleSubmitRenameOrNewStack = async (loadMeta: boolean, stack?: any, stac
<div v-e="['a:kanban:delete-record']" class="flex items-center gap-2 nc-kanban-context-menu-item">
<component :is="iconMap.delete" class="flex" />
<!-- Delete Record -->
{{ $t('activity.deleteRecord') }}
{{
$t('general.deleteEntity', {
entity: $t('objects.record').toLowerCase(),
})
}}
</div>
</NcMenuItem>
</NcMenu>

6
packages/nc-gui/components/smartsheet/PlainCell.vue

@ -37,7 +37,11 @@ const { basesUser } = storeToRefs(basesStore)
const { isXcdbBase, isMssql, isMysql } = useBase()
const sqlUi = ref(column.value?.source_id ? sqlUis.value[column.value?.source_id] : Object.values(sqlUis.value)[0])
const sqlUi = ref(
column.value?.source_id && sqlUis.value[column.value?.source_id]
? sqlUis.value[column.value?.source_id]
: Object.values(sqlUis.value)[0],
)
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

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

@ -6,6 +6,8 @@ const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
const { isViewsLoading } = storeToRefs(useViewsStore())
const { isLocalMode } = useViewColumnsOrThrow()
const containerRef = ref<HTMLElement>()
const { width } = useElementSize(containerRef)
@ -14,6 +16,15 @@ const isTab = computed(() => {
if (!isCalendar.value) return false
return width.value > 1200
})
const isToolbarIconMode = computed(() => {
if (width.value < 768) {
return true
}
return false
})
provide(IsToolbarIconMode, isToolbarIconMode)
</script>
<template>
@ -49,7 +60,7 @@ const isTab = computed(() => {
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery || isKanban || isMap" />
<LazySmartsheetToolbarGroupByMenu v-if="isGrid" />
<LazySmartsheetToolbarGroupByMenu v-if="isGrid && !isLocalMode" />
<LazySmartsheetToolbarSortListMenu v-if="isGrid || isGallery || isKanban" />
</div>

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

@ -38,14 +38,19 @@ const isSharedBase = computed(() => route.value.params.typeOrId === 'base')
<div
v-if="extensionsEgg"
class="flex items-center px-2 py-1 gap-2 border-1 rounded-lg h-8 xs:(h-10 ml-0) ml-1 border-gray-200 cursor-pointer font-weight-600"
:class="{ 'bg-orange-50': isPanelExpanded, 'text-orange-500': isPanelExpanded }"
class="flex items-center px-2 py-1 border-1 rounded-lg h-8 xs:(h-10 ml-0) ml-1 border-gray-200 cursor-pointer font-weight-600 text-sm select-none"
:class="{ 'bg-brand-50 text-brand-500': isPanelExpanded }"
@click="toggleExtensionPanel"
>
<GeneralIcon icon="puzzle" :class="{ 'border-l-1 border-white': isPanelExpanded }" />
Extensions
<GeneralIcon icon="puzzle" class="w-4 h-4" :class="{ 'border-l-1 border-transparent': isPanelExpanded }" />
<span
class="overflow-hidden trasition-all duration-200"
:class="{ 'w-[0px] invisible': isPanelExpanded, 'ml-2 w-[74px]': !isPanelExpanded }"
>
Extensions
</span>
</div>
<div v-else class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<div v-else-if="!extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<LazyGeneralShareProject
v-if="(isForm || isGrid || isKanban || isGallery || isMap || isCalendar) && !isPublic && !isMobileMode"

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

@ -363,6 +363,7 @@ const isFullUpdateAllowed = computed(() => {
'min-w-[500px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
'overflow-visible': formState.uidt === UITypes.Formula,
'!w-[600px]': formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links,
'min-w-[422px] !w-full': isLinksOrLTAR(formState.uidt),
'shadow-lg shadow-gray-300 border-1 border-gray-200 rounded-xl p-5': !embedMode,
}"
@keydown="handleEscape"
@ -491,7 +492,7 @@ const isFullUpdateAllowed = computed(() => {
<SmartsheetColumnRollupOptions v-if="formState.uidt === UITypes.Rollup" v-model:value="formState" />
<SmartsheetColumnLinkedToAnotherRecordOptions
v-if="formState.uidt === UITypes.LinkToAnotherRecord || formState.uidt === UITypes.Links"
:key="`${formState.uidt}-${formState.id || formState.title}`"
:key="`${formState.uidt}-${formState.id || 'new'}`"
v-model:value="formState"
:is-edit="isEdit"
/>

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

@ -37,7 +37,7 @@ const supportedColumns = computed(
return false
}
if (isHiddenCol(col)) {
if (isHiddenCol(col, meta.value)) {
return false
}

9
packages/nc-gui/components/smartsheet/column/LinkAdvancedOptions.vue

@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
value: any
}>()
</script>
<template>
<span></span>
</template>

261
packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ModelTypes, MssqlUi, RelationTypes, SqliteUi, UITypes, ViewTypes } from 'nocodb-sdk'
import { type LinkToAnotherRecordType, ModelTypes, MssqlUi, RelationTypes, SqliteUi, UITypes, ViewTypes } from 'nocodb-sdk'
const props = defineProps<{
value: any
@ -47,9 +47,39 @@ if (!isEdit.value) {
if (!vModel.value.onDelete) vModel.value.onDelete = onUpdateDeleteOptions[0]
if (!vModel.value.virtual) vModel.value.virtual = sqlUi === SqliteUi // appInfo.isCloud || sqlUi === SqliteUi
if (!vModel.value.alias) vModel.value.alias = vModel.value.column_name
} else {
const colOptions = vModel.value?.colOptions as LinkToAnotherRecordType
if (vModel.value?.meta?.custom && isEeUI) {
let ref_column_id = colOptions.fk_child_column_id
let column_id = colOptions.fk_parent_column_id
// extract ref column id from colOptions
if (
colOptions.type === RelationTypes.MANY_TO_MANY ||
colOptions.type === RelationTypes.BELONGS_TO ||
vModel?.value?.meta?.bt
) {
ref_column_id = colOptions.fk_parent_column_id
column_id = colOptions.fk_child_column_id
}
vModel.value.custom = {
ref_model_id: colOptions?.fk_related_model_id,
base_id: meta.value?.base_id,
junc_base_id: meta.value?.base_id,
junc_model_id: colOptions?.fk_mm_model_id,
junc_ref_column_id: colOptions?.fk_mm_parent_column_id,
junc_column_id: colOptions?.fk_mm_child_column_id,
ref_column_id,
column_id,
}
}
vModel.value.is_custom_link = vModel.value?.meta?.custom
if (!vModel.value.childViewId) vModel.value.childViewId = vModel.value?.colOptions?.fk_target_view_id || null
}
if (!vModel.value.childId) vModel.value.childId = vModel.value?.colOptions?.fk_related_model_id || null
if (!vModel.value.childViewId) vModel.value.childViewId = vModel.value?.colOptions?.fk_target_view_id || null
if (!vModel.value.type) vModel.value.type = vModel.value?.colOptions?.type || 'mm'
const advancedOptions = ref(false)
@ -157,6 +187,42 @@ const handleUpdateRefTable = () => {
updateFieldName()
})
}
const isAdvancedOptionsShownEasterEgg = ref(false)
const cusValidators = {
'custom.column_id': [{ required: true, message: t('general.required') }],
'custom.ref_model_id': [{ required: true, message: t('general.required') }],
'custom.ref_column_id': [{ required: true, message: t('general.required') }],
}
const cusJuncTableValidations = {
'custom.junc_model_id': [{ required: true, message: t('general.required') }],
'custom.junc_column_id': [{ required: true, message: t('general.required') }],
'custom.junc_ref_column_id': [{ required: true, message: t('general.required') }],
}
const onCustomSwitchToggle = () => {
if (vModel.value?.is_custom_link) {
setAdditionalValidations({
childId: [],
...cusValidators,
...(vModel.value.type === RelationTypes.MANY_TO_MANY ? cusJuncTableValidations : {}),
})
vModel.value.virtual = true
} else
setAdditionalValidations({
childId: [{ required: true, message: t('general.required') }],
})
}
const handleShowAdvanceOptions = () => {
isAdvancedOptionsShownEasterEgg.value = !isAdvancedOptionsShownEasterEgg.value
if (!isAdvancedOptionsShownEasterEgg.value) {
vModel.value.is_custom_link = false
}
}
</script>
<template>
@ -176,7 +242,7 @@ const handleUpdateRefTable = () => {
</span>
{{ $t('title.hasMany') }}
</a-radio>
<a-radio value="oo" data-testid="One to One">
<a-radio value="oo" data-testid="One to One" @dblclick="handleShowAdvanceOptions">
<span class="nc-ltar-icon nc-oo-icon">
<GeneralIcon icon="oneToOneSolid" />
</span>
@ -184,8 +250,23 @@ const handleUpdateRefTable = () => {
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item class="flex w-full nc-ltar-child-table" v-bind="validateInfos.childId">
</div>
<div v-if="isAdvancedOptionsShownEasterEgg && isEeUI">
<a-switch
v-model:checked="vModel.is_custom_link"
:disabled="isEdit"
:is-edit="isEdit"
size="small"
name="Custom"
@change="onCustomSwitchToggle"
/>
<span class="ml-3">Advanced Link</span>
</div>
<div v-if="isEeUI && vModel.is_custom_link">
<LazySmartsheetColumnLinkAdvancedOptions v-model:value="vModel" :is-edit="isEdit" :meta="meta" />
</div>
<template v-else>
<a-form-item class="flex w-full pb-2 nc-ltar-child-table" v-bind="validateInfos.childId">
<a-select
v-model:value="referenceTableChildId"
show-search
@ -211,75 +292,75 @@ const handleUpdateRefTable = () => {
</a-select-option>
</a-select>
</a-form-item>
</template>
<div v-if="isEeUI" class="w-full flex-col">
<div class="flex gap-2 items-center" :class="{ 'mb-2': limitRecToView }">
<a-switch
v-model:checked="limitRecToView"
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
size="small"
:disabled="!vModel.childId"
@change="onLimitRecToViewChange"
></a-switch>
<span
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
class="text-s"
data-testid="nc-limit-record-view"
@click="limitRecToView = !!vModel.childId && !limitRecToView"
>Limit record selection to a view</span
>
</div>
<a-form-item v-if="limitRecToView" class="!pl-8 flex w-full pb-2 mt-4 space-y-2 nc-ltar-child-view">
<NcSelect
v-model:value="vModel.childViewId"
:placeholder="$t('labels.selectView')"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-ltar-child-view"
>
<a-select-option v-for="view of refViews" :key="view.title" :value="view.id">
<div class="flex w-full items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="view" class="text-gray-500" />
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ view.title }}</template>
<span>{{ view.title }}</span>
</NcTooltip>
<template v-if="isEeUI">
<div class="flex gap-2 items-center" :class="{ 'mb-2': limitRecToView }">
<a-switch
v-model:checked="limitRecToView"
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
size="small"
:disabled="!vModel.childId"
@change="onLimitRecToViewChange"
></a-switch>
<span
v-e="['c:link:limit-record-by-view', { status: limitRecToView }]"
class="text-s"
data-testid="nc-limit-record-view"
@click="limitRecToView = !!vModel.childId && !limitRecToView"
>Limit record selection to a view</span
>
</div>
<a-form-item v-if="limitRecToView" class="!pl-8 flex w-full pb-2 mt-4 space-y-2 nc-ltar-child-view">
<NcSelect
v-model:value="vModel.childViewId"
:placeholder="$t('labels.selectView')"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-ltar-child-view"
>
<a-select-option v-for="view of refViews" :key="view.title" :value="view.id">
<div class="flex w-full items-center gap-2">
<div class="min-w-5 flex items-center justify-center">
<GeneralViewIcon :meta="view" class="text-gray-500" />
</div>
</a-select-option>
</NcSelect>
</a-form-item>
<div class="mt-4 flex gap-2 items-center" :class="{ 'mb-2': limitRecToCond }">
<a-switch
v-model:checked="limitRecToCond"
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
:disabled="!vModel.childId"
size="small"
></a-switch>
<span
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
data-testid="nc-limit-record-filters"
@click="limitRecToCond = !!vModel.childId && !limitRecToCond"
>
Limit record selection to filters
</span>
</div>
<div v-if="limitRecToCond" class="overflow-auto">
<LazySmartsheetToolbarColumnFilter
ref="filterRef"
v-model="vModel.filters"
class="!pl-8 !p-0 max-w-620px"
:auto-save="false"
:show-loading="false"
:link="true"
:root-meta="meta"
:link-col-id="vModel.id"
/>
</div>
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ view.title }}</template>
<span>{{ view.title }}</span>
</NcTooltip>
</div>
</a-select-option>
</NcSelect>
</a-form-item>
<div class="flex gap-2 items-center" :class="{ 'mb-2': limitRecToCond }">
<a-switch
v-model:checked="limitRecToCond"
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
:disabled="!vModel.childId"
size="small"
></a-switch>
<span
v-e="['c:link:limit-record-by-filter', { status: limitRecToCond }]"
data-testid="nc-limit-record-filters"
@click="limitRecToCond = !!vModel.childId && !limitRecToCond"
>
Limit record selection to filters
</span>
</div>
</div>
<div v-if="limitRecToCond" class="overflow-auto">
<LazySmartsheetToolbarColumnFilter
ref="filterRef"
v-model="vModel.filters"
class="!pl-8 !p-0 max-w-620px"
:auto-save="false"
:show-loading="false"
:link="true"
:root-meta="meta"
:link-col-id="vModel.id"
/>
</div>
</template>
<template v-if="(!isXcdbBase && !isEdit) || isLinks">
<div>
<NcButton
@ -354,7 +435,7 @@ const handleUpdateRefTable = () => {
<div class="flex flex-row">
<a-form-item>
<div class="flex items-center gap-1">
<NcSwitch v-model:checked="vModel.virtual" @change="onDataTypeChange">
<NcSwitch v-model:checked="vModel.virtual" :disabled="vModel.is_custom_link" @change="onDataTypeChange">
<div class="text-sm text-gray-800 select-none">
{{ $t('title.virtualRelation') }}
</div>
@ -401,3 +482,41 @@ const handleUpdateRefTable = () => {
@apply h-8.5;
}
</style>
<!-- todo: remove later
<style lang="scss" scoped>
.nc-ltar-relation-type-radio-group {
.nc-ltar-icon {
@apply flex items-center p-1 rounded;
&.nc-mm-icon {
@apply bg-pink-500;
}
&.nc-hm-icon {
@apply bg-orange-500;
}
&.nc-oo-icon {
@apply bg-purple-500;
:deep(svg path) {
@apply stroke-purple-50;
}
}
}
:deep(.ant-radio-wrapper) {
@apply px-3 py-2 flex items-center mr-0;
&:not(:last-child) {
@apply border-b border-gray-200;
}
}
:deep(.ant-radio) {
@apply top-0;
& + span {
@apply flex items-center gap-2;
}
}
}
</style>
-->

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

@ -42,7 +42,9 @@ const refTables = computed(() => {
.map((column) => ({
col: column.colOptions,
column,
...tables.value.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id),
...(tables.value.find((table) => table.id === (column.colOptions as LinkToAnotherRecordType).fk_related_model_id) ||
metas.value[(column.colOptions as LinkToAnotherRecordType).fk_related_model_id!] ||
{}),
}))
.filter((table) => (table.col as LinkToAnotherRecordType)?.fk_related_model_id === table.id && !table.mm)
return _refTables as Required<TableType & { column: ColumnType; col: Required<LinkToAnotherRecordType> }>[]

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

@ -514,7 +514,10 @@ const isColumnValid = (column: TableExplorerColumn) => {
return false
}
if ((column.uidt === UITypes.Links || column.uidt === UITypes.LinkToAnotherRecord) && isNew) {
if (!column.childColumn || !column.childTable || !column.childId) {
if (
(!column.childColumn || !column.childTable || !column.childId) &&
(!column.custom?.ref_model_id || !column.custom?.ref_column_id)
) {
return false
}
}
@ -1208,7 +1211,7 @@ watch(
@click="duplicateField(field)"
>
<GeneralIcon icon="duplicate" class="text-gray-800" />
<span>{{ $t('general.duplicate') }}</span>
<span>{{ $t('general.duplicate') }} {{ $t('objects.field').toLowerCase() }}</span>
</NcMenuItem>
<NcMenuItem
v-if="!field.pv"
@ -1238,7 +1241,7 @@ watch(
>
<div class="text-red-500">
<GeneralIcon icon="delete" class="group-hover:text-accent -ml-0.25 -mt-0.75 mr-0.5" />
{{ $t('general.delete') }}
{{ $t('general.delete') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</template>

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

@ -186,6 +186,18 @@ function scrollToComment(commentId: string) {
}
}
function scrollToAudit(auditId?: string) {
if (!auditId) return
const auditEl = commentsWrapperEl.value?.querySelector(`.nc-audit-item.${auditId}`)
if (auditEl) {
auditEl.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
}
watch(commentsWrapperEl, () => {
setTimeout(() => {
nextTick(() => {
@ -261,6 +273,17 @@ function handleResetHoverEffect() {
hoveredCommentId.value = null
}
watch(
() => audits.value.length,
(auditCount) => {
nextTick(() => {
setTimeout(() => {
scrollToAudit(audits.value[auditCount - 1]?.id)
}, 100)
})
},
)
</script>
<template>
@ -530,7 +553,7 @@ function handleResetHoverEffect() {
</div>
</template>
<div v-for="audit of audits" :key="audit.id" class="nc-audit-item">
<div v-for="audit of audits" :key="audit.id" :class="`${audit.id}`" class="nc-audit-item">
<div class="group gap-3 overflow-hidden px-3 py-2 hover:bg-gray-100">
<div class="flex items-start justify-between">
<div class="flex items-start gap-3 flex-1 w-full">

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

@ -48,6 +48,8 @@ const { copy } = useClipboard()
const { isMobileMode } = useGlobal()
const { fieldsMap, isLocalMode } = useViewColumnsOrThrow()
const { t } = useI18n()
const rowId = toRef(props, 'rowId')
@ -102,11 +104,17 @@ const fields = computedInject(FieldsInj, (_fields) => {
return _fields?.value ?? []
})
const displayField = computed(() => meta.value?.columns?.find((c) => c.pv && fields.value.includes(c)) ?? null)
const displayField = computed(() => meta.value?.columns?.find((c) => c.pv && fields.value?.includes(c)) ?? null)
const hiddenFields = computed(() => {
// todo: figure out when meta.value is undefined
return (meta.value?.columns ?? []).filter((col) => !fields.value?.includes(col)).filter((col) => !isSystemColumn(col))
return (meta.value?.columns ?? [])
.filter(
(col) =>
!fields.value?.includes(col) &&
(isLocalMode.value && col?.id && fieldsMap.value[col.id] ? fieldsMap.value[col.id]?.initialShow : true),
)
.filter((col) => !isSystemColumn(col))
})
const showHiddenFields = ref(false)
@ -315,7 +323,9 @@ const reloadHook = createEventHook()
reloadHook.on(() => {
reloadParentRowHook?.trigger({ shouldShowLoading: false })
if (isNew.value) return
_loadRow(null, true)
_loadRow(undefined, true)
loadAudits(rowId.value, false)
})
provide(ReloadRowDataHookInj, reloadHook)
@ -685,7 +695,7 @@ export default {
<NcMenuItem class="text-gray-700" @click="_loadRow()">
<div v-e="['c:row-expand:reload']" class="flex gap-2 items-center" data-testid="nc-expanded-form-reload">
<component :is="iconMap.reload" class="cursor-pointer" />
{{ $t('general.reload') }}
{{ $t('general.reload') }} {{ $t('objects.record') }}
</div>
</NcMenuItem>
<NcMenuItem
@ -717,7 +727,11 @@ export default {
<div v-e="['c:row-expand:delete']" class="flex gap-2 items-center" data-testid="nc-expanded-form-delete">
<component :is="iconMap.delete" class="cursor-pointer nc-delete-row" />
<span class="-ml-0.25">
{{ $t('activity.deleteRecord') }}
{{
$t('general.deleteEntity', {
entity: $t('objects.record').toLowerCase(),
})
}}
</span>
</div>
</NcMenuItem>

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

@ -398,7 +398,7 @@ const bgColor = computed(() => {
@change="findAndLoadSubGroup"
>
<a-collapse-panel
v-for="[i, grp] of Object.entries(vGroup?.children ?? [])"
v-for="[_, grp] of Object.entries(vGroup?.children ?? [])"
:key="`group-panel-${grp.key}`"
class="!border-1 border-gray-300 nc-group rounded-[8px] mb-2"
:style="`background: ${bgColor};`"

4
packages/nc-gui/components/smartsheet/grid/GroupByTable.vue

@ -46,10 +46,10 @@ const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const { eventBus } = useSmartsheetStoreOrThrow()
const routeQuery = computed(() => route.value.query as Record<string, string>)
const route = router.currentRoute
const routeQuery = computed(() => route.value.query as Record<string, string>)
const expandedFormDlg = ref(false)
const expandedFormRow = ref<Row>()
const expandedFormRowState = ref<Record<string, any>>()

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

@ -109,7 +109,7 @@ const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const openNewRecordFormHook = inject(OpenNewRecordFormHookInj, createEventHook())
const { isMobileMode } = useGlobal()
const { isMobileMode, isAddNewRecordGridMode, setAddNewRecordGridMode } = useGlobal()
const scrollParent = inject(ScrollParentInj, ref<undefined>())
@ -196,8 +196,6 @@ const preloadColumn = ref<any>()
const scrolling = ref(false)
const isAddNewRecordGridMode = ref(true)
const switchingTab = ref(false)
const isView = false
@ -493,12 +491,12 @@ const onDraftRecordClick = () => {
}
const onNewRecordToGridClick = () => {
isAddNewRecordGridMode.value = true
setAddNewRecordGridMode(true)
addEmptyRow()
}
const onNewRecordToFormClick = () => {
isAddNewRecordGridMode.value = false
setAddNewRecordGridMode(false)
onDraftRecordClick()
}
@ -1910,7 +1908,7 @@ onKeyStroke('ArrowDown', onDown)
}"
>
<div
class="absolute top-0 w-[40px]"
class="absolute top-0 w-45"
:class="{
'left-[60px]': isAddingColumnAllowed,
'left-0': !isAddingColumnAllowed,
@ -1938,7 +1936,7 @@ onKeyStroke('ArrowDown', onDown)
'mobile': isMobileMode,
'desktop': !isMobileMode,
'w-full': dataRef.length === 0,
'pr-60 pb-12': !headerOnly && !isGroupBy,
'pb-12': !headerOnly && !isGroupBy,
}"
:style="{
transform: `translateY(${topOffset}px) translateX(${leftOffset}px)`,
@ -2268,7 +2266,7 @@ onKeyStroke('ArrowDown', onDown)
<div v-e="['a:row:copy']" class="flex gap-2 items-center">
<GeneralIcon icon="copy" />
<!-- Copy -->
{{ $t('general.copy') }}
{{ $t('general.copy') }} {{ $t('objects.cell').toLowerCase() }}
</div>
</NcMenuItem>
@ -2282,7 +2280,7 @@ onKeyStroke('ArrowDown', onDown)
<div v-e="['a:row:paste']" class="flex gap-2 items-center">
<GeneralIcon icon="paste" />
<!-- Paste -->
{{ $t('general.paste') }}
{{ $t('general.paste') }} {{ $t('objects.cell').toLowerCase() }}
</div>
</NcMenuItem>
@ -2302,7 +2300,7 @@ onKeyStroke('ArrowDown', onDown)
>
<div v-e="['a:row:clear']" class="flex gap-2 items-center">
<GeneralIcon icon="close" />
{{ $t('general.clear') }}
{{ $t('general.clear') }} {{ $t('objects.cell').toLowerCase() }}
</div>
</NcMenuItem>
@ -2316,7 +2314,7 @@ onKeyStroke('ArrowDown', onDown)
>
<div v-e="['a:row:clear-range']" class="flex gap-2 items-center">
<GeneralIcon icon="closeBox" class="text-gray-500" />
{{ $t('general.clear') }}
{{ $t('general.clear') }} {{ $t('objects.cell').toLowerCase() }}
</div>
</NcMenuItem>
@ -2325,7 +2323,7 @@ onKeyStroke('ArrowDown', onDown)
<NcMenuItem class="nc-base-menu-item" @click="commentRow(contextMenuTarget.row)">
<div v-e="['a:row:comment']" class="flex gap-2 items-center">
<MdiMessageOutline class="h-4 w-4" />
{{ $t('general.comment') }}
{{ $t('general.add') }} {{ $t('general.comment').toLowerCase() }}
</div>
</NcMenuItem>
</template>

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

@ -109,6 +109,8 @@ export const useColumnDrag = ({
const handleReorderColumn = async () => {
isProcessing.value = true
try {
if (!dragColPlaceholderDomRef.value) return
dragColPlaceholderDomRef.value!.style.left = '0px'
dragColPlaceholderDomRef.value!.style.height = '0px'
await reorderColumn(draggedCol.value!.id!, toBeDroppedColId.value!)
@ -126,6 +128,8 @@ export const useColumnDrag = ({
const dom = document.querySelector('[data-testid="drag-icon-placeholder"]')
if (!dom || !dragColPlaceholderDomRef.value) return
e.dataTransfer.dropEffect = 'none'
e.dataTransfer.effectAllowed = 'none'

28
packages/nc-gui/components/smartsheet/header/DeleteColumnModal.vue

@ -27,6 +27,31 @@ const viewsStore = useViewsStore()
const isLoading = ref(false)
// disable for time being - internal discussion required
/*
const warningMsg = computed(() => {
if (!column?.value) return []
const columns = meta?.value?.columns.filter((c) => {
if (isLinksOrLTAR(c) && c.colOptions) {
return (
(c.colOptions as LinkToAnotherRecordType).fk_parent_column_id === column.value?.id ||
(c.colOptions as LinkToAnotherRecordType).fk_child_column_id === column.value?.id ||
(c.colOptions as LinkToAnotherRecordType).fk_mm_child_column_id === column.value?.id ||
(c.colOptions as LinkToAnotherRecordType).fk_mm_parent_column_id === column.value?.id
)
}
return false
})
if (!columns.length) return null
return `This column is used in following Link column${columns.length > 1 ? 's' : ''}: '${columns
.map((c) => c.title)
.join("', '")}'. Deleting this column will also delete the related Link column${columns.length > 1 ? 's' : ''}.`
}) */
const onDelete = async () => {
if (!column?.value) return
@ -80,5 +105,8 @@ const onDelete = async () => {
</div>
</div>
</template>
<!-- disable for time being - internal discussion required -->
<!-- <template v-if="warningMsg" #warning>{{ warningMsg }}</template> -->
</GeneralDeleteModal>
</template>

25
packages/nc-gui/components/smartsheet/header/Menu.vue

@ -328,7 +328,8 @@ const isDuplicateAllowed = computed(() => {
return (
column?.value &&
!column.value.system &&
((!isMetaReadOnly.value && !isDataReadOnly.value) || readonlyMetaAllowedTypes.includes(column.value?.uidt))
((!isMetaReadOnly.value && !isDataReadOnly.value) || readonlyMetaAllowedTypes.includes(column.value?.uidt)) &&
!column.value.meta?.custom
)
})
const isFilterSupported = computed(
@ -373,6 +374,13 @@ const isColumnEditAllowed = computed(() => {
return false
return true
})
// check if the column is associated as foreign key in any of the link column
const linksAssociated = computed(() => {
return meta.value?.columns?.filter(
(c) => isLinksOrLTAR(c) && [c.colOptions?.fk_child_column_id, c.colOptions?.fk_parent_column_id].includes(column?.value?.id),
)
})
</script>
<template>
@ -398,17 +406,17 @@ const isColumnEditAllowed = computed(() => {
<GeneralSourceRestrictionTooltip message="Field properties cannot be edited." :enabled="!isColumnEditAllowed">
<NcMenuItem
v-if="isUIAllowed('fieldAlter')"
:disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed"
:disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed || linksAssociated.length"
:title="linksAssociated.length ? 'Field is associated with a link column' : undefined"
@click="onEditPress"
>
<div class="nc-column-edit nc-header-menu-item">
<component :is="iconMap.ncEdit" class="text-gray-700" />
<!-- Edit -->
{{ $t('general.edit') }}
{{ $t('general.edit') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
<GeneralSourceRestrictionTooltip message="Field cannot be duplicated." :enabled="!isDuplicateAllowed">
<NcMenuItem
v-if="isUIAllowed('duplicateColumn') && isExpandedForm && !column?.pk"
@ -418,7 +426,7 @@ const isColumnEditAllowed = computed(() => {
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-700" />
<!-- Duplicate -->
{{ t('general.duplicate') }}
{{ t('general.duplicate') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
@ -530,7 +538,7 @@ const isColumnEditAllowed = computed(() => {
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-700" />
<!-- Duplicate -->
{{ t('general.duplicate') }}
{{ t('general.duplicate') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
@ -553,8 +561,9 @@ const isColumnEditAllowed = computed(() => {
<GeneralSourceRestrictionTooltip message="Field cannot be deleted." :enabled="!isColumnUpdateAllowed">
<NcMenuItem
v-if="!column?.pv && isUIAllowed('fieldDelete')"
:disabled="!isDeleteAllowed || !isColumnUpdateAllowed"
:disabled="!isDeleteAllowed || !isColumnUpdateAllowed || linksAssociated.length"
class="!hover:bg-red-50"
:title="linksAssociated ? 'Field is associated with a link column' : undefined"
@click="handleDelete"
>
<div
@ -563,7 +572,7 @@ const isColumnEditAllowed = computed(() => {
>
<component :is="iconMap.delete" />
<!-- Delete -->
{{ $t('general.delete') }}
{{ $t('general.delete') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>

7
packages/nc-gui/components/smartsheet/toolbar/Calendar/Range.vue

@ -12,6 +12,11 @@ const isLocked = inject(IsLockedInj, ref(false))
const IsPublic = inject(IsPublicInj, ref(false))
const isToolbarIconMode = inject(
IsToolbarIconMode,
computed(() => false),
)
const { loadViewColumns } = useViewColumnsOrThrow()
const { loadCalendarMeta, loadCalendarData, loadSidebarData, fetchActiveDates, updateCalendarMeta, viewMetaProperties } =
@ -154,7 +159,7 @@ const saveCalendarRange = async (range: CalendarRangeType, value?) => {
>
<div class="flex items-center gap-2">
<component :is="iconMap.calendar" class="h-4 w-4 transition-all group-hover:text-brand-500" />
<span class="text-capitalize !group-hover:text-brand-500 !text-[13px] font-medium">
<span v-if="!isToolbarIconMode" class="text-capitalize !group-hover:text-brand-500 !text-[13px] font-medium">
{{ $t('activity.settings') }}
</span>
</div>

9
packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue

@ -5,6 +5,11 @@ const isLocked = inject(IsLockedInj, ref(false))
const activeView = inject(ActiveViewInj, ref())
const isToolbarIconMode = inject(
IsToolbarIconMode,
computed(() => false),
)
const { isMobileMode } = useGlobal()
const filterComp = ref<typeof ColumnFilter>()
@ -76,7 +81,9 @@ eventBus.on(async (event, column: ColumnType) => {
<div class="flex items-center gap-2">
<component :is="iconMap.filter" class="h-4 w-4" />
<!-- Filter -->
<span v-if="!isMobileMode" class="text-capitalize !text-[13px] font-medium">{{ $t('activity.filter') }}</span>
<span v-if="!isMobileMode && !isToolbarIconMode" class="text-capitalize !text-[13px] font-medium">{{
$t('activity.filter')
}}</span>
</div>
<span v-if="filtersLength" class="bg-brand-50 text-brand-500 py-1 px-2 text-md rounded-md">{{ filtersLength }}</span>
</div>

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

@ -32,7 +32,7 @@ const options = computed<ColumnType[]>(
return false
}
if (isHiddenCol(c)) {
if (isHiddenCol(c, meta.value)) {
/** ignore mm relation column, created by and last modified by system field */
return false
}

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

@ -27,7 +27,7 @@ const options = computed<ColumnType[]>(
return true
}
if (isSystemColumn(metaColumnById?.value?.[c.id!])) {
if (isHiddenCol(c)) {
if (isHiddenCol(c, meta.value)) {
/** ignore mm relation column, created by and last modified by system field */
return false
}

38
packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue

@ -24,13 +24,22 @@ const localValue = computed({
set: (val) => emit('update:modelValue', val),
})
const { showSystemFields, metaColumnById } = useViewColumnsOrThrow()
const { showSystemFields, metaColumnById, fieldsMap, isLocalMode } = useViewColumnsOrThrow()
const options = computed<SelectProps['options']>(() =>
(
customColumns.value?.filter((c: ColumnType) => {
if (
isLocalMode.value &&
c?.id &&
fieldsMap.value[c.id] &&
(!fieldsMap.value[c.id]?.initialShow || (!showSystemFields.value && isSystemColumn(metaColumnById?.value?.[c.id!])))
) {
return false
}
if (isSystemColumn(metaColumnById?.value?.[c.id!])) {
if (isHiddenCol(c)) {
if (isHiddenCol(c, meta.value)) {
/** ignore mm relation column, created by and last modified by system field */
return false
}
@ -38,11 +47,20 @@ const options = computed<SelectProps['options']>(() =>
return true
}) ||
meta.value?.columns?.filter((c: ColumnType) => {
if (
isLocalMode.value &&
c?.id &&
fieldsMap.value[c.id] &&
(!fieldsMap.value[c.id]?.initialShow || (!showSystemFields.value && isSystemColumn(metaColumnById?.value?.[c.id!])))
) {
return false
}
if (c.uidt === UITypes.Links) {
return true
}
if (isSystemColumn(metaColumnById?.value?.[c.id!])) {
if (isHiddenCol(c)) {
if (isHiddenCol(c, meta.value)) {
/** ignore mm relation column, created by and last modified by system field */
return false
}
@ -65,10 +83,11 @@ const options = computed<SelectProps['options']>(() =>
}
})
)
// sort and keep system columns at the end
// sort by view column order and keep system columns at the end
?.sort((field1, field2) => {
let orderVal1 = 0
let orderVal2 = 0
let sortByOrder = 0
if (isSystemColumn(field1)) {
orderVal1 = 1
@ -77,7 +96,16 @@ const options = computed<SelectProps['options']>(() =>
orderVal2 = 1
}
return orderVal1 - orderVal2
if (
field1?.id &&
field2?.id &&
fieldsMap.value[field1.id]?.order !== undefined &&
fieldsMap.value[field2.id]?.order !== undefined
) {
sortByOrder = fieldsMap.value[field1.id].order - fieldsMap.value[field2.id].order
}
return orderVal1 - orderVal2 || sortByOrder
})
?.map((c: ColumnType) => ({
value: c.id,

36
packages/nc-gui/components/smartsheet/toolbar/FieldListWithSearch.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
import { type ColumnType, isSystemColumn } from 'nocodb-sdk'
const props = defineProps<{
// As we need to focus search box when the parent is opened
@ -13,10 +13,42 @@ const props = defineProps<{
const emits = defineEmits<{ selected: [ColumnType] }>()
const { isParentOpen, toolbarMenu, searchInputPlaceholder, selectedOptionId, options, showSelectedOption } = toRefs(props)
const { isParentOpen, toolbarMenu, searchInputPlaceholder, selectedOptionId, showSelectedOption } = toRefs(props)
const { fieldsMap, isLocalMode } = useViewColumnsOrThrow()
const searchQuery = ref('')
const options = computed(() =>
(props.options || [])
.filter((c) => (isLocalMode.value && c?.id && fieldsMap.value[c.id] ? fieldsMap.value[c.id]?.initialShow : true))
.map((c) => c)
.sort((field1, field2) => {
// sort by view column order and keep system columns at the end
let orderVal1 = 0
let orderVal2 = 0
let sortByOrder = 0
if (isSystemColumn(field1)) {
orderVal1 = 1
}
if (isSystemColumn(field2)) {
orderVal2 = 1
}
if (
field1?.id &&
field2?.id &&
fieldsMap.value[field1.id]?.order !== undefined &&
fieldsMap.value[field2.id]?.order !== undefined
) {
sortByOrder = fieldsMap.value[field1.id].order - fieldsMap.value[field2.id].order
}
return orderVal1 - orderVal2 || sortByOrder
}),
)
const filteredOptions = computed(
() => options.value?.filter((c: ColumnType) => c.title?.toLowerCase().includes(searchQuery.value.toLowerCase())) ?? [],
)

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

@ -19,6 +19,11 @@ const isLocked = inject(IsLockedInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const isToolbarIconMode = inject(
IsToolbarIconMode,
computed(() => false),
)
const { $api, $e } = useNuxtApp()
const { t } = useI18n()
@ -29,6 +34,7 @@ const {
showSystemFields,
fields,
filteredFieldList,
numberOfHiddenFields,
filterQuery,
showAll,
hideAll,
@ -37,6 +43,7 @@ const {
loadViewColumns,
toggleFieldStyles,
toggleFieldVisibility,
isLocalMode,
} = useViewColumnsOrThrow()
const { eventBus, isDefaultView } = useSmartsheetStoreOrThrow()
@ -51,8 +58,6 @@ eventBus.on((event) => {
}
})
const numberOfHiddenFields = computed(() => filteredFieldList.value?.filter((field) => !field.show)?.length)
const gridDisplayValueField = computed(() => {
if (activeView.value?.type !== ViewTypes.GRID && activeView.value?.type !== ViewTypes.CALENDAR) return null
const pvCol = Object.values(metaColumnById.value)?.find((col) => col?.pv)
@ -449,7 +454,7 @@ useMenuCloseOnEsc(open)
<component :is="iconMap.fields" v-else class="h-4 w-4" />
<!-- Fields -->
<span v-if="!isMobileMode" class="text-capitalize !text-[13px] font-medium">
<span v-if="!isMobileMode && !isToolbarIconMode" class="text-capitalize !text-[13px] font-medium">
<template v-if="activeView?.type === ViewTypes.KANBAN || activeView?.type === ViewTypes.GALLERY">
{{ $t('title.editCards') }}
</template>
@ -608,7 +613,7 @@ useMenuCloseOnEsc(open)
<component :is="iconMap.drag" class="cursor-move !h-3.75 text-gray-600 mr-1" />
<div
v-e="['a:fields:show-hide']"
class="flex flex-row items-center w-full truncate cursor-pointer ml-1 py-[5px] pr-2"
class="flex flex-row items-center w-full cursor-pointer truncate ml-1 py-[5px] pr-2"
@click="
() => {
field.show = !field.show
@ -662,7 +667,7 @@ useMenuCloseOnEsc(open)
:checked="field.show"
:disabled="field.isViewEssentialField"
size="xsmall"
@change="$t('a:fields:show-hide')"
@change="$e('a:fields:show-hide')"
/>
</div>
@ -677,7 +682,7 @@ useMenuCloseOnEsc(open)
{{ showAllColumns ? $t('general.hideAll') : $t('general.showAll') }} {{ $t('objects.fields').toLowerCase() }}
</NcButton>
<NcButton
v-if="!isPublic"
v-if="!isLocalMode"
class="nc-fields-show-system-fields"
size="small"
type="ghost"

6
packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue

@ -65,7 +65,11 @@ type FilterType = keyof typeof checkTypeFunctions
const { sqlUis } = storeToRefs(useBase())
const sqlUi = ref(column.value?.source_id ? sqlUis.value[column.value?.source_id] : Object.values(sqlUis.value)[0])
const sqlUi = ref(
column.value?.source_id && sqlUis.value[column.value?.source_id]
? sqlUis.value[column.value?.source_id]
: Object.values(sqlUis.value)[0],
)
const abstractType = computed(() => column.value && sqlUi.value.getAbstractType(column.value))

11
packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue

@ -4,9 +4,16 @@ import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sd
import Draggable from 'vuedraggable'
const meta = inject(MetaInj, ref())
const view = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false))
const isToolbarIconMode = inject(
IsToolbarIconMode,
computed(() => false),
)
const { gridViewCols, updateGridViewColumn, metaColumnById, showSystemFields } = useViewColumnsOrThrow()
const { fieldsToGroupBy, groupByLimit } = useViewGroupByOrThrow()
@ -201,7 +208,9 @@ const onMove = async (event: { moved: { newIndex: number; oldIndex: number } })
<component :is="iconMap.group" class="h-4 w-4" />
<!-- Group By -->
<span v-if="!isMobileMode" class="text-capitalize !text-[13px] font-medium">{{ $t('activity.group') }}</span>
<span v-if="!isMobileMode && !isToolbarIconMode" class="text-capitalize !text-[13px] font-medium">{{
$t('activity.group')
}}</span>
</div>
<span v-if="groupedByColumnIds?.length" class="bg-brand-50 text-brand-500 py-1 px-2 text-md rounded-md">{{
groupedByColumnIds.length

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

@ -72,8 +72,8 @@ const updateRowHeight = async (rh: number, undo = false) => {
;(view.value.view as GridType).row_height = rh
open.value = false
} catch (e) {
message.error('There was an error while updating view!')
} catch (e: any) {
message.error((await extractSdkResponseErrorMsg(e)) || 'There was an error while updating view!')
}
}
}

6
packages/nc-gui/components/smartsheet/toolbar/SearchData.vue

@ -21,7 +21,11 @@ const globalSearchWrapperRef = ref<HTMLInputElement>()
const { isMobileMode } = useGlobal()
const columns = computed(
() => (meta.value as TableType)?.columns?.filter((column) => !isSystemColumn(column) && column?.uidt !== UITypes.Links) ?? [],
() =>
(meta.value as TableType)?.columns?.filter(
(column) =>
!isSystemColumn(column) && ![UITypes.Links, UITypes.Rollup, UITypes.DateTime, UITypes.Date].includes(column?.uidt),
) ?? [],
)
watch(

9
packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue

@ -22,6 +22,11 @@ const { getPlanLimit } = useWorkspace()
const isCalendar = inject(IsCalendarInj, ref(false))
const isToolbarIconMode = inject(
IsToolbarIconMode,
computed(() => false),
)
eventBus.on((event) => {
if (event === SmartsheetStoreEvents.SORT_RELOAD) {
loadSorts()
@ -128,7 +133,9 @@ onMounted(() => {
<component :is="iconMap.sort" class="h-4 w-4 text-inherit" />
<!-- Sort -->
<span v-if="!isMobileMode" class="text-capitalize !text-[13px] font-medium">{{ $t('activity.sort') }}</span>
<span v-if="!isMobileMode && !isToolbarIconMode" class="text-capitalize !text-[13px] font-medium">{{
$t('activity.sort')
}}</span>
</div>
<span v-if="sorts?.length" class="bg-brand-50 text-brand-500 py-1 px-2 text-md rounded-md">{{ sorts.length }}</span>
</div>

7
packages/nc-gui/components/smartsheet/toolbar/StackedBy.vue

@ -13,6 +13,11 @@ const IsPublic = inject(IsPublicInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false))
const isToolbarIconMode = inject(
IsToolbarIconMode,
computed(() => false),
)
const { fields, loadViewColumns, metaColumnById } = useViewColumnsOrThrow(activeView, meta)
const { kanbanMetaData, loadKanbanMeta, loadKanbanData, updateKanbanMeta, groupingField } = useKanbanViewStoreOrThrow()
@ -141,7 +146,7 @@ const getIcon = (c: ColumnType) =>
>
<div class="flex items-center gap-2">
<GeneralIcon icon="settings" class="h-4 w-4" />
<div class="flex items-center gap-0.5">
<div v-if="!isToolbarIconMode" class="flex items-center gap-0.5">
<span class="text-capitalize !text-sm flex items-center gap-1 text-gray-700">
{{ $t('activity.kanban.stackedBy') }}
</span>

22
packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue

@ -176,18 +176,30 @@ const onDelete = async () => {
<NcDivider />
<NcMenuItem v-if="lockType !== LockType.Locked" @click="onRenameMenuClick">
<GeneralIcon icon="rename" />
{{ $t('activity.renameView') }}
{{
$t('general.renameEntity', {
entity: view.type !== ViewTypes.FORM ? $t('objects.view').toLowerCase() : $t('objects.viewType.form').toLowerCase(),
})
}}
</NcMenuItem>
<NcTooltip v-else>
<template #title> {{ $t('msg.info.disabledAsViewLocked') }} </template>
<NcMenuItem class="!cursor-not-allowed !text-gray-400">
<GeneralIcon icon="rename" />
{{ $t('activity.renameView') }}
{{
$t('general.renameEntity', {
entity: view.type !== ViewTypes.FORM ? $t('objects.view').toLowerCase() : $t('objects.viewType.form').toLowerCase(),
})
}}
</NcMenuItem>
</NcTooltip>
<NcMenuItem @click="onDuplicate">
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" />
{{ $t('labels.duplicateView') }}
{{
$t('general.duplicateEntity', {
entity: view.type !== ViewTypes.FORM ? $t('objects.view').toLowerCase() : $t('objects.viewType.form').toLowerCase(),
})
}}
</NcMenuItem>
</template>
@ -302,7 +314,7 @@ const onDelete = async () => {
<GeneralIcon class="nc-view-delete-icon" icon="delete" />
{{
$t('general.deleteEntity', {
entity: $t('objects.view'),
entity: view.type !== ViewTypes.FORM ? $t('objects.view').toLowerCase() : $t('objects.viewType.form').toLowerCase(),
})
}}
</NcMenuItem>
@ -311,7 +323,7 @@ const onDelete = async () => {
<GeneralIcon class="nc-view-delete-icon" icon="delete" />
{{
$t('general.deleteEntity', {
entity: $t('objects.view'),
entity: view.type !== ViewTypes.FORM ? $t('objects.view').toLowerCase() : $t('objects.viewType.form').toLowerCase(),
})
}}
</NcMenuItem>

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

@ -80,6 +80,8 @@ useProvideSmartsheetLtarHelpers(meta)
const grid = ref()
const extensionPaneRef = ref()
const onDrop = async (event: DragEvent) => {
event.preventDefault()
try {
@ -159,7 +161,7 @@ watch([activeViewTitleOrId, activeTableId], () => {
handleSidebarOpenOnMobileForNonViews()
})
const { extensionPanelSize } = useExtensions()
const { isPanelExpanded, extensionPanelSize } = useExtensions()
const onResize = (sizes: { min: number; max: number; size: number }[]) => {
if (sizes.length === 2) {
@ -167,14 +169,28 @@ const onResize = (sizes: { min: number; max: number; size: number }[]) => {
extensionPanelSize.value = sizes[1].size
}
}
const onReady = () => {
if (isPanelExpanded.value && extensionPaneRef.value) {
// wait until extension pane animation complete
setTimeout(() => {
extensionPaneRef.value?.onReady()
}, 300)
}
}
</script>
<template>
<div class="nc-container flex flex-col h-full" @drop="onDrop" @dragover.prevent>
<LazySmartsheetTopbar />
<div style="height: calc(100% - var(--topbar-height))">
<Splitpanes v-if="openedViewsTab === 'view'" class="nc-extensions-content-resizable-wrapper" @resized="onResize">
<Pane class="flex flex-col h-full flex-1 min-w-0" size="60">
<Splitpanes
v-if="openedViewsTab === 'view'"
class="nc-extensions-content-resizable-wrapper"
@ready="onReady"
@resized="onResize"
>
<Pane class="flex flex-col h-full min-w-0" :size="isPanelExpanded && extensionPanelSize ? 100 - extensionPanelSize : 100">
<LazySmartsheetToolbar v-if="!isForm" />
<div
:style="{ height: isForm || isMobileMode ? '100%' : 'calc(100% - var(--toolbar-height))' }"
@ -201,7 +217,7 @@ const onResize = (sizes: { min: number; max: number; size: number }[]) => {
</Transition>
</div>
</Pane>
<ExtensionsPane />
<ExtensionsPane ref="extensionPaneRef" />
</Splitpanes>
<SmartsheetDetails v-else />
</div>

264
packages/nc-gui/components/template/Editor.vue

@ -101,7 +101,7 @@ const isImporting = ref(false)
const importingTips = ref<Record<string, string>>({})
const checkAllRecord = ref<boolean[]>([])
const checkAllRecord = ref<Record<string, boolean>>({})
const formError = ref()
@ -145,7 +145,11 @@ const validators = computed(() =>
hasSelectColumn.value[tableIdx] = false
table.columns?.forEach((column, columnIdx) => {
acc[`tables.${tableIdx}.columns.${columnIdx}.title`] = [fieldRequiredValidator(), fieldLengthValidator()]
acc[`tables.${tableIdx}.columns.${columnIdx}.title`] = [
fieldRequiredValidator(),
fieldLengthValidator(),
reservedFieldNameValidator(),
]
acc[`tables.${tableIdx}.columns.${columnIdx}.uidt`] = [fieldRequiredValidator()]
if (isSelect(column)) {
hasSelectColumn.value[tableIdx] = true
@ -168,14 +172,20 @@ watch(
let res = true
if (importDataOnly) {
for (const tn of Object.keys(srcDestMapping.value)) {
let flag = false
if (!atLeastOneEnabledValidation(tn)) {
res = false
}
for (const record of srcDestMapping.value[tn]) {
if (!fieldsValidation(record, tn)) {
return false
res = false
flag = true
break
}
}
if (flag) {
break
}
}
} else {
for (const [_, o] of Object.entries(validateInfos)) {
@ -231,6 +241,10 @@ function parseTemplate({ tables = [], ...rest }: Props['baseTemplate']) {
...rest,
columns: [
...columns.map((c: any, idx: number) => {
if (!importDataOnly && c.column_name?.toLowerCase() === 'id') {
const cn = populateUniqueColumnName('id', [], columns)
c.column_name = cn
}
c.key = idx
return c
}),
@ -258,20 +272,13 @@ function deleteTable(tableIdx: number) {
function deleteTableColumn(tableIdx: number, columnKey: number) {
const columnIdx = data.tables[tableIdx].columns.findIndex((c: ColumnType & { key: number }) => c.key === columnKey)
data.tables[tableIdx].columns.splice(columnIdx, 1)
}
function addNewColumnRow(tableIdx: number, uidt: string) {
data.tables[tableIdx].columns.push({
key: data.tables[tableIdx].columns.length,
title: `title${data.tables[tableIdx].columns.length + 1}`,
column_name: `title${data.tables[tableIdx].columns.length + 1}`,
uidt,
})
let key = 0
nextTick(() => {
const input = inputRefs.value[data.tables[tableIdx].columns.length - 1]
input.focus()
input.select()
data.tables[tableIdx].columns.forEach((_c: ColumnType & { key: number }, i: number) => {
if (data.tables[tableIdx].columns[i].key !== undefined) {
data.tables[tableIdx].columns[i].key = key
key++
}
})
}
@ -283,6 +290,9 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
const dateFormatMap: Record<number, string> = {}
return batchData.map((data) =>
(columns || []).reduce((aggObj, col: Record<string, any>) => {
// we renaming existing id column and using our own auto increment id
if (col.uidt === UITypes.ID) return aggObj
// for excel & json, if the column name is changed in TemplateEditor,
// then only col.column_name exists in data, else col.ref_column_name
// for csv, col.column_name always exists in data
@ -571,6 +581,7 @@ async function importTemplate() {
await $api.dbTableColumn.primaryColumnSet(createdTable.columns[0].id as string)
}
}
// bulk insert data
if (importData) {
const offset = maxRowsToParse
@ -624,7 +635,7 @@ function mapDefaultColumns() {
srcDestMapping.value = {}
for (let i = 0; i < data.tables.length; i++) {
for (const col of importColumns[i]) {
const o = { srcCn: col.column_name, destCn: '', enabled: true }
const o = { srcCn: col.column_name, srcTitle: col.title, destCn: '', enabled: true }
if (columns.value) {
const tableColumn = columns.value.find((c) => c.column_name === col.column_name)
if (tableColumn) {
@ -713,6 +724,20 @@ const setErrorState = (errorsFields: any[]) => {
formError.value = errorMap
}
function populateUniqueColumnName(cn: string, draftCn: string[] = [], columns: ColumnType[]) {
let c = 2
let columnName = `${cn}${1}`
while (
draftCn.includes(columnName) ||
columns?.some((c) => {
return c.column_name === columnName || c.title === columnName
})
) {
columnName = `${cn}${c++}`
}
return columnName
}
watch(formRef, () => {
setTimeout(async () => {
try {
@ -763,7 +788,7 @@ watch(modelRef, async () => {
</template>
<template #extra>
<a-tooltip bottom>
<NcTooltip bottom class="inline-block">
<template #title>
<span>{{ $t('activity.deleteTable') }}</span>
</template>
@ -773,7 +798,7 @@ watch(modelRef, async () => {
class="text-lg mr-8"
@click.stop="deleteTable(tableIdx)"
/>
</a-tooltip>
</NcTooltip>
</template>
<a-table
@ -801,10 +826,10 @@ watch(modelRef, async () => {
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'source_column'">
<NcTooltip class="truncate"
><template #title>{{ record.srcCn }}</template
>{{ record.srcCn }}</NcTooltip
>
<NcTooltip class="truncate inline-block">
<template #title>{{ record.srcTitle }}</template>
{{ record.srcTitle }}
</NcTooltip>
</template>
<template v-else-if="column.key === 'destination_column'">
@ -815,10 +840,13 @@ watch(modelRef, async () => {
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-filter-field"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-current" />
</template>
<a-select-option v-for="(col, i) of columns" :key="i" :value="col.title">
<div class="flex items-center">
<component :is="getUIDTIcon(col.uidt)" />
<span class="ml-2">{{ col.title }}</span>
<div class="flex items-center gap-2">
<component :is="getUIDTIcon(col.uidt)" class="w-3.5 h-3.5" />
<span>{{ col.title }}</span>
</div>
</a-select-option>
</a-select>
@ -849,13 +877,13 @@ watch(modelRef, async () => {
<a-collapse-panel v-for="(table, tableIdx) of data.tables" :key="tableIdx">
<template #header>
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.table_name`]" no-style>
<div class="flex flex-col w-full">
<div class="flex flex-col w-full mr-2">
<a-input
v-model:value="table.table_name"
class="font-weight-bold text-lg"
class="font-weight-bold text-lg !rounded-md"
size="large"
hide-details
:bordered="false"
:bordered="true"
@click.stop
@blur="handleEditableTnChange(tableIdx)"
@keydown.enter="handleEditableTnChange(tableIdx)"
@ -869,17 +897,17 @@ watch(modelRef, async () => {
</template>
<template #extra>
<a-tooltip bottom>
<NcTooltip bottom class="inline-block mr-8">
<template #title>
<span>{{ $t('activity.deleteTable') }}</span>
</template>
<component
:is="iconMap.delete"
:is="iconMap.deleteListItem"
v-if="data.tables.length > 1"
class="text-lg mr-8"
class="text-lg"
@click.stop="deleteTable(tableIdx)"
/>
</a-tooltip>
</NcTooltip>
</template>
<a-table
v-if="table.columns && table.columns.length"
@ -915,118 +943,84 @@ watch(modelRef, async () => {
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'column_name'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.${column.key}`]">
<a-input :ref="(el: HTMLInputElement) => (inputRefs[record.key] = el)" v-model:value="record.title" />
</a-form-item>
</template>
<template v-else-if="column.key === 'uidt'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.${column.key}`]">
<a-select
v-model:value="record.uidt"
class="w-52"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-template-uidt"
@change="handleUIDTChange(record, table)"
<a-form-item
v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.title`]"
class="nc-table-field-name"
>
<a-input
:ref="(el: HTMLInputElement) => (inputRefs[record.key] = el)"
v-model:value="record.title"
class="!rounded-md"
>
<a-select-option v-for="(option, i) of uiTypeOptions" :key="i" :value="option.value">
<a-tooltip placement="right">
<template v-if="isSelectDisabled(option.label, table.columns[record.key]?._disableSelect)" #title>
{{
$t('msg.tooLargeFieldEntity', {
entity: option.label,
})
}}
<template #suffix>
<NcTooltip v-if="formError?.[`tables.${tableIdx}.columns.${record.key}.title`]" class="flex">
<template #title
>{{ formError?.[`tables.${tableIdx}.columns.${record.key}.title`].join('\n') }}
</template>
{{ option.label }}
</a-tooltip>
</a-select-option>
</a-select>
<GeneralIcon icon="info" class="h-4 w-4 text-red-500 flex-none" />
</NcTooltip>
</template>
</a-input>
</a-form-item>
</template>
<template v-else-if="column.key === 'dtxp'">
<a-form-item v-if="isSelect(record)">
<a-input v-model:value="record.dtxp" />
<template v-else-if="column.key === 'uidt'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.uidt`]">
<NcTooltip :disabled="importDataOnly">
<template #title>
{{ $t('tooltip.useFieldEditMenuToConfigFieldType') }}
</template>
<a-select
v-model:value="record.uidt"
class="w-52"
show-search
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-template-uidt"
:disabled="!importDataOnly"
@change="handleUIDTChange(record, table)"
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-current" />
</template>
<a-select-option v-for="(option, i) of uiTypeOptions" :key="i" :value="option.value">
<div class="flex items-center gap-2">
<component :is="getUIDTIcon(UITypes[option.value])" class="h-3.5 w-3.5" />
<NcTooltip placement="right" :disabled="!importDataOnly" show-on-truncate-only>
<template v-if="isSelectDisabled(option.label, table.columns[record.key]?._disableSelect)" #title>
{{
$t('msg.tooLargeFieldEntity', {
entity: option.label,
})
}}
</template>
{{ option.label }}
</NcTooltip>
</div>
</a-select-option>
</a-select>
</NcTooltip>
</a-form-item>
</template>
<template v-if="column.key === 'action'">
<a-tooltip v-if="record.key === 0">
<template #title>
<span>{{ $t('general.primaryValue') }}</span>
</template>
<div class="flex items-center float-right mr-4">
<mdi-key-star class="text-lg" />
</div>
</a-tooltip>
<a-tooltip v-else>
<NcTooltip class="inline-block">
<template #title>
<span>{{ $t('activity.column.delete') }}</span>
</template>
<a-button type="text" @click="deleteTableColumn(tableIdx, record.key)">
<div class="flex items-center">
<component :is="iconMap.delete" class="text-lg" />
</div>
</a-button>
</a-tooltip>
<NcButton
type="text"
size="small"
:disabled="table.columns.length === 1"
@click="deleteTableColumn(tableIdx, record.key)"
>
<component :is="iconMap.deleteListItem" />
</NcButton>
</NcTooltip>
</template>
</template>
</a-table>
<div class="mt-5 flex gap-2 justify-center">
<a-tooltip bottom>
<template #title>
<span>{{ $t('activity.column.addNumber') }}</span>
</template>
<a-button class="group" @click="addNewColumnRow(tableIdx, 'Number')">
<div class="flex items-center">
<component :is="iconMap.number" class="group-hover:!text-accent flex text-lg" />
</div>
</a-button>
</a-tooltip>
<a-tooltip bottom>
<template #title>
<span>{{ $t('activity.column.addSingleLineText') }}</span>
</template>
<a-button class="group" @click="addNewColumnRow(tableIdx, 'SingleLineText')">
<div class="flex items-center">
<component :is="iconMap.text" class="group-hover:!text-accent text-lg" />
</div>
</a-button>
</a-tooltip>
<a-tooltip bottom>
<template #title>
<span>{{ $t('activity.column.addLongText') }}</span>
</template>
<a-button class="group" @click="addNewColumnRow(tableIdx, 'LongText')">
<div class="flex items-center">
<component :is="iconMap.longText" class="group-hover:!text-accent text-lg" />
</div>
</a-button>
</a-tooltip>
<a-tooltip bottom>
<template #title>
<span>{{ $t('activity.column.addOther') }}</span>
</template>
<a-button class="group" @click="addNewColumnRow(tableIdx, 'SingleLineText')">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" class="group-hover:!text-accent text-lg" />
</div>
</a-button>
</a-tooltip>
</div>
</a-collapse-panel>
</a-collapse>
</a-form>
@ -1051,4 +1045,16 @@ watch(modelRef, async () => {
}
}
}
:deep(.ant-collapse-header) {
@apply !items-center;
& > div {
@apply flex;
}
}
.nc-table-field-name {
:deep(.ant-form-item-explain) {
@apply hidden;
}
}
</style>

11
packages/nc-gui/components/template/utils.ts

@ -13,14 +13,15 @@ export const tableColumns: (Omit<ColumnGroupType<any>, 'children'> & { dataIndex
key: 'uidt',
width: 250,
},
{
name: 'Select Option',
key: 'dtxp',
},
// {
// name: 'Select Option',
// key: 'dtxp',
// },
{
name: 'Action',
key: 'action',
align: 'right',
align: 'center',
width: 40,
},
]

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

@ -87,8 +87,8 @@ watch(value, (next) => {
<template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }">
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center w-full">
<div class="nc-cell-field chips flex items-center flex-1" :class="{ 'max-w-[calc(100%_-_16px)]': !isUnderLookup }">
<div class="nc-cell-field flex items-center w-full">
<div class="chips flex items-center flex-1" :class="{ 'max-w-[calc(100%_-_16px)]': !isUnderLookup }">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"

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

@ -127,7 +127,7 @@ watch(
<template>
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center gap-1 w-full chips-wrapper min-h-4">
<div class="nc-cell-field flex items-center gap-1 w-full chips-wrapper min-h-4">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip

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

@ -126,7 +126,7 @@ watch(
<template>
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex items-center gap-1 w-full chips-wrapper min-h-4">
<div class="nc-cell-field flex items-center gap-1 w-full chips-wrapper min-h-4">
<div class="chips flex items-center img-container flex-1 hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cells">
<VirtualCellComponentsItemChip

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

@ -84,8 +84,8 @@ watch(
<template>
<LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="flex w-full chips-wrapper items-center min-h-4" :class="{ active }">
<div class="nc-cell-field chips flex items-center flex-1 max-w-[calc(100%_-_16px)]">
<div class="nc-cell-field flex w-full chips-wrapper items-center min-h-4" :class="{ active }">
<div class="chips flex items-center flex-1 max-w-[calc(100%_-_16px)]">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
<VirtualCellComponentsItemChip
:item="value"

7
packages/nc-gui/components/virtual-cell/Rollup.vue

@ -1,4 +1,5 @@
<script setup lang="ts">
import { UITypes, getRenderAsTextFunForUiType } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, RollupType } from 'nocodb-sdk'
const { metas } = useMetas()
@ -37,11 +38,15 @@ const childColumn = computed(() => {
}
return ''
})
const renderAsTextFun = computed(() => {
return getRenderAsTextFunForUiType(childColumn.value?.uidt || UITypes.SingleLineText)
})
</script>
<template>
<div class="nc-cell-field" @dblclick="activateShowEditNonEditableFieldWarning">
<div v-if="['count', 'avg', 'sum', 'countDistinct', 'sumDistinct', 'avgDistinct'].includes(colOptions.rollup_function)">
<div v-if="renderAsTextFun.includes(colOptions.rollup_function)">
{{ value }}
</div>
<LazySmartsheetCell v-else v-model="value" :column="childColumn" :edit-enabled="false" :read-only="true" />

49
packages/nc-gui/components/virtual-cell/components/ItemChip.vue

@ -27,6 +27,8 @@ const isForm = inject(IsFormInj)!
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const { open } = useExpandedFormDetached()
function openExpandedForm() {
@ -40,6 +42,7 @@ function openExpandedForm() {
meta: relatedTableMeta.value,
rowId,
useMetaFields: true,
loadRow: !isPublic.value,
})
}
}
@ -58,7 +61,7 @@ export default {
:class="{ active, 'border-1 py-1 px-2': isAttachment(column) }"
@click="openExpandedForm"
>
<div class="text-ellipsis overflow-hidden">
<div class="text-ellipsis overflow-hidden pointer-events-none">
<span class="name">
<!-- Render virtual cell -->
<div v-if="isVirtualCol(column)">
@ -112,5 +115,49 @@ export default {
white-space: nowrap;
word-break: keep-all;
}
:deep(.nc-action-icon) {
@apply invisible;
}
:deep(.nc-cell) {
&.nc-cell-longtext {
.long-text-wrapper {
@apply min-h-1;
.nc-readonly-rich-text-wrapper {
@apply !min-h-1;
}
.nc-rich-text {
@apply pl-0;
.tiptap.ProseMirror {
@apply -ml-1 min-h-1;
}
}
}
}
&.nc-cell-checkbox {
@apply children:pl-0;
& > div {
@apply !h-auto;
}
}
&.nc-cell-singleselect .nc-cell-field > div {
@apply flex items-center;
}
&.nc-cell-multiselect .nc-cell-field > div {
@apply h-5;
}
&.nc-cell-email,
&.nc-cell-phonenumber {
@apply flex items-center;
}
&.nc-cell-email,
&.nc-cell-phonenumber,
&.nc-cell-url {
.nc-cell-field-link {
@apply py-0;
}
}
}
}
</style>

16
packages/nc-gui/components/workspace/AuditLogs.vue

@ -476,7 +476,7 @@ useEventListener(tableWrapper, 'scroll', () => {
</div>
</div>
</template>
<div v-if="selectedAudit" class="flex flex-col gap-4">
<div v-if="selectedAudit" class="nc-expanded-audit flex flex-col gap-4">
<div class="bg-gray-50 rounded-lg border-1 border-gray-200">
<div class="flex">
<div class="w-1/2 border-r border-gray-200 flex flex-col gap-2 px-4 py-3">
@ -548,7 +548,9 @@ useEventListener(tableWrapper, 'scroll', () => {
</div>
<div class="flex flex-col gap-2">
<div class="cell-header">{{ $t('labels.description') }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.description }}</div>
<div>
<pre class="!text-small !leading-[18px] !text-gray-600 mb-0">{{ selectedAudit?.description }}</pre>
</div>
</div>
</div>
</NcModal>
@ -557,13 +559,9 @@ useEventListener(tableWrapper, 'scroll', () => {
</template>
<style lang="scss" scoped>
.nc-audit-table pre {
display: table;
table-layout: fixed;
width: 100%;
white-space: break-spaces;
font-size: unset;
font-family: unset;
.nc-expanded-audit pre {
font-family: Manrope, 'Inter', 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
}
:deep(.nc-menu-item-inner) {

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

@ -79,6 +79,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const formState = ref<Record<string, any>>({
title: '',
uidt: fromTableExplorer?.value ? defaultType : null,
custom: {},
...clone(column.value || {}),
})
@ -89,6 +90,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const colProp = sqlUi.value.getDataTypeForUiType(formState.value as { uidt: UITypes }, idType ?? undefined)
formState.value = {
custom: {},
...(!isEdit.value && {
// only take title, column_name and uidt when creating a column
// to avoid the extra props from being taken (e.g. SingleLineText -> LTAR -> SingleLineText)
@ -284,6 +286,9 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
try {
formState.value.table_name = meta.value?.table_name
const refModelId = formState.value.custom?.ref_model_id
// formState.value.title = formState.value.column_name
if (column.value) {
// reset column validation if column is not to be validated
@ -337,7 +342,11 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
/** if LTAR column then force reload related table meta */
if (isLinksOrLTAR(formState.value) && meta.value?.id !== formState.value.childId) {
getMeta(formState.value.childId, true).then(() => {})
if (refModelId) {
getMeta(refModelId, true).then(() => {})
} else {
getMeta(formState.value.childId, true).then(() => {})
}
}
// Column created

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

@ -173,7 +173,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
}
}
const loadAudits = async (_rowId?: string) => {
const loadAudits = async (_rowId?: string, showLoading: boolean = true) => {
if (!isUIAllowed('auditListRow') || isEeUI || (!row.value && !_rowId)) return
const rowId = _rowId ?? extractPkFromRow(row.value.row, meta.value.columns as ColumnType[])
@ -181,7 +181,9 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
if (!rowId) return
try {
isAuditLoading.value = true
if (showLoading) {
isAuditLoading.value = true
}
const res =
(
await $api.utils.auditList({
@ -329,7 +331,7 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
if (missingRequiredColumns.size) return
data = await $api.dbTableRow.create('noco', base.value.id as string, meta.value.id, {
data = await $api.dbTableRow.create('noco', meta.value.base_id, meta.value.id, {
...insertObj,
...(ltarState || {}),
})

44
packages/nc-gui/composables/useExtensions.ts

@ -1,6 +1,14 @@
import type { ExtensionsEvents } from '#imports'
const extensionsState = createGlobalState(() => {
const baseExtensions = ref<Record<string, any>>({})
return { baseExtensions }
// Egg
const extensionsEgg = ref(false)
const extensionsEggCounter = ref(0)
return { baseExtensions, extensionsEgg, extensionsEggCounter }
})
interface ExtensionManifest {
@ -35,12 +43,14 @@ abstract class ExtensionType {
export { ExtensionType }
export const useExtensions = createSharedComposable(() => {
const { baseExtensions } = extensionsState()
const { baseExtensions, extensionsEgg, extensionsEggCounter } = extensionsState()
const { $api } = useNuxtApp()
const { base } = storeToRefs(useBase())
const eventBus = useEventBus<ExtensionsEvents>(Symbol('useExtensions'))
const extensionsLoaded = ref(false)
const availableExtensions = ref<ExtensionManifest[]>([])
@ -329,20 +339,20 @@ export const useExtensions = createSharedComposable(() => {
}
})
}
until(base)
.toMatch((v) => !!v)
.then(() => {
if (!base.value || !base.value.id) {
return
}
if (!baseExtensions.value[base.value.id]) {
loadExtensionsForBase(base.value.id)
}
})
})
watch(
() => base.value?.id,
(baseId) => {
if (baseId && !baseExtensions.value[baseId]) {
loadExtensionsForBase(baseId)
}
},
{
immediate: true,
},
)
// Extension details modal
const isDetailsVisible = ref(false)
const detailsExtensionId = ref<string>()
@ -357,11 +367,6 @@ export const useExtensions = createSharedComposable(() => {
// Extension market modal
const isMarketVisible = ref(false)
// Egg
const extensionsEgg = ref(false)
const extensionsEggCounter = ref(0)
const onEggClick = () => {
extensionsEggCounter.value++
if (extensionsEggCounter.value >= 2) {
@ -390,5 +395,6 @@ export const useExtensions = createSharedComposable(() => {
onEggClick,
extensionsEgg,
extensionPanelSize,
eventBus,
}
})

13
packages/nc-gui/composables/useGlobal/actions.ts

@ -160,6 +160,17 @@ export function useGlobalActions(state: State): Actions {
state.gridViewPageSize.value = pageSize
}
const setLeftSidebarSize = ({ old, current }: { old?: number; current?: number }) => {
state.leftSidebarSize.value = {
old: old ?? state.leftSidebarSize.value.old,
current: current ?? state.leftSidebarSize.value.current,
}
}
const setAddNewRecordGridMode = (isGridMode: boolean) => {
state.isAddNewRecordGridMode.value = isGridMode
}
return {
signIn,
signOut,
@ -171,5 +182,7 @@ export function useGlobalActions(state: State): Actions {
ncNavigateTo,
getMainUrl,
setGridViewPageSize,
setLeftSidebarSize,
setAddNewRecordGridMode,
}
}

6
packages/nc-gui/composables/useGlobal/state.ts

@ -1,6 +1,7 @@
import { useStorage } from '@vueuse/core'
import type { JwtPayload } from 'jwt-decode'
import type { AppInfo, State, StoredState } from './types'
import { INITIAL_LEFT_SIDEBAR_WIDTH } from '~/lib/constants'
export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
/** get the preferred languages of a user, according to browser settings */
@ -57,6 +58,11 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
isMobileMode: null,
lastOpenedWorkspaceId: null,
gridViewPageSize: 25,
leftSidebarSize: {
old: INITIAL_LEFT_SIDEBAR_WIDTH,
current: INITIAL_LEFT_SIDEBAR_WIDTH,
},
isAddNewRecordGridMode: true,
}
/** saves a reactive state, any change to these values will write/delete to localStorage */

7
packages/nc-gui/composables/useGlobal/types.ts

@ -53,6 +53,11 @@ export interface StoredState {
isMobileMode: boolean | null
lastOpenedWorkspaceId: string | null
gridViewPageSize: number
leftSidebarSize: {
old: number
current: number
}
isAddNewRecordGridMode: boolean
}
export type State = ToRefs<Omit<StoredState, 'token'>> & {
@ -89,6 +94,8 @@ export interface Actions {
getBaseUrl: (workspaceId: string) => string | undefined
getMainUrl: (workspaceId: string) => string | undefined
setGridViewPageSize: (pageSize: number) => void
setLeftSidebarSize: (params: { old?: number; current?: number }) => void
setAddNewRecordGridMode: (isGridMode: boolean) => void
}
export type ReadonlyState = Readonly<Pick<State, 'token' | 'user'>> & Omit<State, 'token' | 'user'>

1
packages/nc-gui/composables/useJobs.ts

@ -13,6 +13,7 @@ interface JobType {
base_id: string
created_at: Date
updated_at: Date
extension_id: string
}
export const useJobs = createSharedComposable(() => {

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

@ -1172,7 +1172,7 @@ export function useMultiSelect(
for (const col of cols) {
if (!col.title || !isPasteable(row, col)) {
if ((isBt(col) || isOo(pasteCol) || isMm(col)) && !isInfoShown) {
if ((isBt(col) || isOo(col) || isMm(col)) && !isInfoShown) {
message.info(t('msg.info.groupPasteIsNotSupportedOnLinksColumn'))
isInfoShown = true
}

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

@ -190,7 +190,7 @@ export const useRoles = () => {
})
const isDataReadOnly = computed(() => {
return currentSource.value?.is_schema_readonly || false
return currentSource.value?.is_data_readonly || false
})
return {

15
packages/nc-gui/composables/useServerConfig.ts

@ -0,0 +1,15 @@
const useServerConfig = () => {
const getConfig = async () => {}
const checkMaintenance = async () => {}
const dismissMaintenance = () => {}
return {
getConfig,
checkMaintenance,
dismissMaintenance,
}
}
export default useServerConfig

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

@ -217,6 +217,10 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
) {
return reject(t('msg.error.fieldRequired'))
}
if (column.uidt === UITypes.Rating && (!value || Number(value) < 1)) {
return reject(t('msg.error.fieldRequired'))
}
}
return resolve()

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

@ -97,7 +97,7 @@ export function useSharedView() {
if (meta.value) {
meta.value.columns = [...viewMeta.model.columns]
.filter((c) => c.show || rangeFields.includes(c.id))
.filter((c) => c.show || rangeFields.includes(c.id) || (sharedView.value?.type === ViewTypes.GRID && c?.group_by))
.map((c) => ({ ...c, order: order++ }))
.sort((a, b) => a.order - b.order)
}

12
packages/nc-gui/composables/useViewAggregate.ts

@ -1,5 +1,13 @@
import type { Ref } from 'vue'
import { type ColumnType, CommonAggregations, type TableType, UITypes, type ViewType, getAvailableAggregations } from 'nocodb-sdk'
import {
type ColumnType,
CommonAggregations,
type TableType,
UITypes,
type ViewType,
ViewTypes,
getAvailableAggregations,
} from 'nocodb-sdk'
const [useProvideViewAggregate, useViewAggregate] = useInjectionState(
(
@ -77,6 +85,8 @@ const [useProvideViewAggregate, useViewAggregate] = useInjectionState(
timeout: 10000,
})
.then(async () => {
if (!view.value?.type || view.value?.type !== ViewTypes.GRID) return
try {
const data = !isPublic.value
? await api.dbDataTableAggregate.dbDataTableAggregate(meta.value.id, {

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

@ -21,6 +21,15 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
const fields = ref<Field[]>()
const fieldsMap = computed(() =>
(fields.value || []).reduce<Record<string, any>>((acc, curr) => {
if (curr.fk_column_id) {
acc[curr.fk_column_id] = curr
}
return acc
}, {}),
)
const filterQuery = ref('')
const { $api, $e } = useNuxtApp()
@ -33,11 +42,9 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
const { addUndo, defineViewScope } = useUndoRedo()
const isLocalMode = computed(
() => isPublic || !isUIAllowed('viewFieldEdit') || !isUIAllowed('viewFieldEdit') || isSharedBase.value,
)
const isLocalMode = computed(() => isPublic || !isUIAllowed('viewFieldEdit') || isSharedBase.value)
const localChanges = ref<Field[]>([])
const localChanges = ref<Record<string, Field>>({})
const isColumnViewEssential = (column: ColumnType) => {
// TODO: consider at some point ti delegate this via a cleaner design pattern to view specific check logic
@ -62,6 +69,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
const loadViewColumns = async () => {
if (!meta || !view) return
let order = 1
if (view.value?.id) {
@ -79,7 +87,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
fields.value = meta.value?.columns
?.filter((column: ColumnType) => {
// filter created by and last modified by system columns
if (isHiddenCol(column)) return false
if (isHiddenCol(column, meta.value)) return false
return true
})
.map((column: ColumnType) => {
@ -94,15 +102,19 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
aggregation: currentColumnField?.aggregation ?? CommonAggregations.None,
system: isSystemColumn(metaColumnById?.value?.[currentColumnField.fk_column_id!]),
isViewEssentialField: isColumnViewEssential(column),
initialShow:
currentColumnField.show ||
isColumnViewEssential(currentColumnField) ||
(currentColumnField as GridColumnType)?.group_by,
}
})
.sort((a: Field, b: Field) => a.order - b.order)
if (isLocalMode.value && fields.value) {
for (const field of localChanges.value) {
const fieldIndex = fields.value.findIndex((f) => f.fk_column_id === field.fk_column_id)
for (const key in localChanges.value) {
const fieldIndex = fields.value.findIndex((f) => f.fk_column_id === key)
if (fieldIndex !== undefined && fieldIndex > -1) {
fields.value[fieldIndex] = field
fields.value[fieldIndex] = localChanges.value[key]
fields.value = fields.value.sort((a: Field, b: Field) => a.order - b.order)
}
}
@ -124,16 +136,20 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
if (isLocalMode.value) {
const fieldById = (fields.value || []).reduce<Record<string, any>>((acc, curr) => {
if (curr.fk_column_id) {
curr.show = true
curr.show = !!curr.initialShow
acc[curr.fk_column_id] = curr
}
return acc
}, {})
fields.value = fields.value?.map((field: Field) => ({
...field,
show: fieldById[field.fk_column_id!]?.show,
}))
fields.value = (fields.value || [])?.map((field: Field) => {
const updateField = {
...field,
show: fieldById[field.fk_column_id!]?.show,
}
localChanges.value[field.fk_column_id!] = field
return updateField
})
meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => {
if (fieldById[column.id!]) {
@ -168,16 +184,20 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
if (isLocalMode.value) {
const fieldById = (fields.value || []).reduce<Record<string, any>>((acc, curr) => {
if (curr.fk_column_id) {
curr.show = !!curr.isViewEssentialField
curr.show = !!metaColumnById?.value?.[curr.fk_column_id!]?.pv || !!curr.isViewEssentialField
acc[curr.fk_column_id] = curr
}
return acc
}, {})
fields.value = fields.value?.map((field: Field) => ({
...field,
show: fieldById[field.fk_column_id!]?.show,
}))
fields.value = (fields.value || [])?.map((field: Field) => {
const updateField = {
...field,
show: fieldById[field.fk_column_id!]?.show,
}
localChanges.value[field.fk_column_id!] = field
return updateField
})
meta.value!.columns = meta.value!.columns?.map((column: ColumnType) => {
if (fieldById[column.id!]) {
@ -228,7 +248,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
return column
})
localChanges.value.push(field)
localChanges.value[field.fk_column_id] = field
}
if (isUIAllowed('viewFieldEdit')) {
@ -275,6 +295,10 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
const filteredFieldList = computed(() => {
return (
fields.value?.filter((field: Field) => {
if (!field.initialShow && isLocalMode.value) {
return false
}
if (
metaColumnById?.value?.[field.fk_column_id!]?.pv &&
(!filterQuery.value || field.title.toLowerCase().includes(filterQuery.value.toLowerCase()))
@ -296,6 +320,27 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
)
})
const numberOfHiddenFields = computed(() => {
return (fields.value || [])
?.filter((field: Field) => {
if (!field.initialShow && isLocalMode.value) {
return false
}
if (metaColumnById?.value?.[field.fk_column_id!]?.pv) {
return true
}
// hide system columns if not enabled
if (!showSystemFields.value && isSystemColumn(metaColumnById?.value?.[field.fk_column_id!])) {
return false
}
return true
})
.filter((field) => !field.show)?.length
})
const sortedAndFilteredFields = computed<ColumnType[]>(() => {
return (fields?.value
?.filter((field: Field) => {
@ -420,8 +465,10 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
return {
fields,
fieldsMap,
loadViewColumns,
filteredFieldList,
numberOfHiddenFields,
filterQuery,
showAll,
hideAll,
@ -435,6 +482,7 @@ const [useProvideViewColumns, useViewColumns] = useInjectionState(
updateGridViewColumn,
gridViewCols,
resizingColOldWith,
isLocalMode,
}
},
'useViewColumnsOrThrow',

67
packages/nc-gui/composables/useViewGroupBy.ts

@ -200,7 +200,7 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
} else if (
[UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(curr.column_uidt as UITypes)
) {
acc += `${acc.length ? '~and' : ''}(${curr.title},eq,exactDate,${curr.key})`
acc += `${acc.length ? '~and' : ''}(${curr.title},gb_eq,exactDate,${curr.key})`
} else if ([UITypes.User, UITypes.CreatedBy, UITypes.LastModifiedBy].includes(curr.column_uidt as UITypes)) {
try {
const value = JSON.parse(curr.key)
@ -364,10 +364,27 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
}
if (appInfo.value.ee) {
const aggregationParams = (group.children ?? []).map((child) => ({
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: child.key,
}))
const aggregationMap = new Map<string, string>()
const aggregationParams = (group.children ?? []).map((child) => {
try {
const key = JSON.parse(child.key)
if (typeof key === 'object') {
const newKey = Math.random().toString(36).substring(7)
aggregationMap.set(newKey, child.key)
return {
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: newKey,
}
}
} catch (e) {}
return {
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: child.key,
}
})
const aggResponse = !isPublic
? await api.dbDataTableBulkAggregate.dbDataTableBulkAggregate(meta.value!.id, {
@ -382,6 +399,14 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
const child = (group?.children ?? []).find((c) => c.key.toString() === key.toString())
if (child) {
Object.assign(child.aggregations, value)
} else {
const originalKey = aggregationMap.get(key)
if (originalKey) {
const child = (group?.children ?? []).find((c) => c.key.toString() === originalKey.toString())
if (child) {
Object.assign(child.aggregations, value)
}
}
}
})
}
@ -439,10 +464,26 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
if (filteredFields && !filteredFields?.length) return
const aggregationParams = (group.children ?? []).map((child) => ({
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: child.key,
}))
const aggregationMap = new Map<string, string>()
const aggregationParams = (group.children ?? []).map((child) => {
try {
const key = JSON.parse(child.key)
if (typeof key === 'object') {
const newKey = Math.random().toString(36).substring(7)
aggregationMap.set(newKey, child.key)
return {
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: newKey,
}
}
} catch (e) {}
return {
where: calculateNestedWhere(child.nestedIn, where?.value),
alias: child.key,
}
})
const response = !isPublic
? await api.dbDataTableBulkAggregate.dbDataTableBulkAggregate(meta.value!.id, {
@ -459,6 +500,14 @@ const [useProvideViewGroupBy, useViewGroupBy] = useInjectionState(
const child = (group.children ?? []).find((c) => c.key.toString() === key.toString())
if (child) {
Object.assign(child.aggregations, value)
} else {
const originalKey = aggregationMap.get(key)
if (originalKey) {
const child = (group.children ?? []).find((c) => c.key.toString() === originalKey.toString())
if (child) {
Object.assign(child.aggregations, value)
}
}
}
})
} catch (e) {

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

Loading…
Cancel
Save