Browse Source

Merge branch 'develop' into 8333-ops-auto-upstall-script-enhancements

pull/8421/head
Rohit T P 5 months ago
parent
commit
9c4047db23
  1. 55
      .github/workflows/bats-test.yml
  2. 4
      docker-compose/setup-script/noco.sh
  3. 8
      docker-compose/setup-script/tests/configure/monitor.bats
  4. 8
      docker-compose/setup-script/tests/configure/restart.bats
  5. 8
      docker-compose/setup-script/tests/configure/scale.bats
  6. 8
      docker-compose/setup-script/tests/configure/start.bats
  7. 8
      docker-compose/setup-script/tests/configure/stop.bats
  8. 8
      docker-compose/setup-script/tests/configure/upgrade.bats
  9. 12
      docker-compose/setup-script/tests/install/default.bats
  10. 8
      docker-compose/setup-script/tests/install/ip.bats
  11. 8
      docker-compose/setup-script/tests/install/redis.bats
  12. 8
      docker-compose/setup-script/tests/install/scale.bats
  13. 8
      docker-compose/setup-script/tests/install/ssl.bats
  14. 8
      docker-compose/setup-script/tests/install/watchtower.bats
  15. 4
      packages/nc-gui/assets/nc-icons/arrow-up-right.svg
  16. 5
      packages/nc-gui/assets/nc-icons/control-panel.svg
  17. 4
      packages/nc-gui/assets/nc-icons/home.svg
  18. 10
      packages/nc-gui/assets/nc-icons/office.svg
  19. 11
      packages/nc-gui/assets/nc-icons/slash.svg
  20. 4
      packages/nc-gui/assets/nc-icons/workspace.svg
  21. 1
      packages/nc-gui/components.d.ts
  22. 32
      packages/nc-gui/components/account/HeaderWithSorter.vue
  23. 33
      packages/nc-gui/components/account/UserList.vue
  24. 79
      packages/nc-gui/components/account/UserMenu.vue
  25. 2
      packages/nc-gui/components/cell/Email.vue
  26. 4
      packages/nc-gui/components/cell/MultiSelect.vue
  27. 12
      packages/nc-gui/components/cell/Rating.vue
  28. 4
      packages/nc-gui/components/cell/Url.vue
  29. 3
      packages/nc-gui/components/dashboard/Sidebar/EEMenuOption.vue
  30. 6
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  31. 6
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  32. 6
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  33. 4
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  34. 3
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  35. 208
      packages/nc-gui/components/dlg/InviteDlg.vue
  36. 5
      packages/nc-gui/components/dlg/TableDelete.vue
  37. 54
      packages/nc-gui/components/dlg/WorkspaceDelete.vue
  38. 3
      packages/nc-gui/components/general/BaseIconColorPicker.vue
  39. 26
      packages/nc-gui/components/general/CopyButton.vue
  40. 2
      packages/nc-gui/components/general/WorkspaceIcon.vue
  41. 7
      packages/nc-gui/components/nc/Badge.vue
  42. 2
      packages/nc-gui/components/nc/ErrorBoundary.vue
  43. 2
      packages/nc-gui/components/nc/Select.vue
  44. 159
      packages/nc-gui/components/project/AccessSettings.vue
  45. 48
      packages/nc-gui/components/project/View.vue
  46. 8
      packages/nc-gui/components/roles/Badge.vue
  47. 8
      packages/nc-gui/components/roles/Selector.vue
  48. 2
      packages/nc-gui/components/shared-view/Calendar.vue
  49. 2
      packages/nc-gui/components/shared-view/Gallery.vue
  50. 5
      packages/nc-gui/components/shared-view/Grid.vue
  51. 2
      packages/nc-gui/components/shared-view/Kanban.vue
  52. 2
      packages/nc-gui/components/shared-view/Map.vue
  53. 136
      packages/nc-gui/components/smartsheet/Cell.vue
  54. 1
      packages/nc-gui/components/smartsheet/Form.vue
  55. 17
      packages/nc-gui/components/smartsheet/Row.vue
  56. 5
      packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue
  57. 4
      packages/nc-gui/components/smartsheet/TableDataCell.vue
  58. 61
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  59. 23
      packages/nc-gui/components/smartsheet/calendar/DayView/DateField.vue
  60. 215
      packages/nc-gui/components/smartsheet/calendar/DayView/DateTimeField.vue
  61. 36
      packages/nc-gui/components/smartsheet/calendar/MonthView.vue
  62. 41
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateField.vue
  63. 601
      packages/nc-gui/components/smartsheet/calendar/WeekView/DateTimeField.vue
  64. 4
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  65. 12
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  66. 6
      packages/nc-gui/components/smartsheet/column/LinkedToAnotherRecordOptions.vue
  67. 32
      packages/nc-gui/components/smartsheet/column/RollupOptions.vue
  68. 4
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  69. 107
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  70. 62
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  71. 1
      packages/nc-gui/components/smartsheet/grid/GroupByTable.vue
  72. 961
      packages/nc-gui/components/smartsheet/grid/Table.vue
  73. 4
      packages/nc-gui/components/smartsheet/grid/index.vue
  74. 30
      packages/nc-gui/components/smartsheet/grid/useColumnDrag.ts
  75. 2
      packages/nc-gui/components/smartsheet/header/Menu.vue
  76. 21
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  77. 24
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  78. 4
      packages/nc-gui/components/smartsheet/toolbar/CreateGroupBy.vue
  79. 7
      packages/nc-gui/components/smartsheet/toolbar/FieldsMenu.vue
  80. 2
      packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue
  81. 4
      packages/nc-gui/components/smartsheet/toolbar/OpenedViewAction.vue
  82. 4
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  83. 10
      packages/nc-gui/components/tabs/Smartsheet.vue
  84. 99
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  85. 127
      packages/nc-gui/components/virtual-cell/HasMany.vue
  86. 136
      packages/nc-gui/components/virtual-cell/Links.vue
  87. 127
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  88. 97
      packages/nc-gui/components/virtual-cell/OneToOne.vue
  89. 2
      packages/nc-gui/components/virtual-cell/QrCode.vue
  90. 117
      packages/nc-gui/components/virtual-cell/components/Header.vue
  91. 85
      packages/nc-gui/components/virtual-cell/components/LinkRecordDropdown.vue
  92. 416
      packages/nc-gui/components/virtual-cell/components/LinkedItems.vue
  93. 291
      packages/nc-gui/components/virtual-cell/components/ListItem.vue
  94. 343
      packages/nc-gui/components/virtual-cell/components/UnLinkedItems.vue
  95. 207
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  96. 180
      packages/nc-gui/components/workspace/Settings.vue
  97. 93
      packages/nc-gui/components/workspace/View.vue
  98. 3
      packages/nc-gui/composables/useCalendarViewStore.ts
  99. 96
      packages/nc-gui/composables/useData.ts
  100. 2
      packages/nc-gui/composables/useExpandedFormDetached/index.ts
  101. Some files were not shown because too many files have changed in this diff Show More

55
.github/workflows/bats-test.yml

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

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

@ -167,8 +167,8 @@ stop_service() {
show_logs_sub_menu() { show_logs_sub_menu() {
clear clear
echo "Select a replica for $1:" echo "Select a replica for $1:"
for i in $(seq 1 "$2"); do for i in $(seq 1 $2); do
echo "$i. $1 replica $i" echo "$i. \"$1\" replica $i"
done done
echo "A. All" echo "A. All"
echo "0. Back to Logs Menu" echo "0. Back to Logs Menu"

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/configure" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Properly runs monitor script" { @test "Properly runs monitor script" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/configure" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check all containers are restarted" { @test "Check all containers are restarted" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/configure" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check NocoDB is scaled to 3 instances" { @test "Check NocoDB is scaled to 3 instances" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/configure" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check all containers are up" { @test "Check all containers are up" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/configure" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check all containers are down" { @test "Check all containers are down" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/configure" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check all containers are upgraded" { @test "Check all containers are upgraded" {

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

@ -6,13 +6,17 @@ export NOCO_HOME
setup() { setup() {
cd "${WORKING_DIR}/install" || exit 1 cd "${WORKING_DIR}/install" || exit 1
./setup.sh ./setup.sh
} }
teardown() { teardown() {
cd "${WORKING_DIR}/install" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check installation with all default options" { @test "Check installation with all default options" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/install" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check installation with custom ip" { @test "Check installation with custom ip" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/install" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check Redis is enabled when specified" { @test "Check Redis is enabled when specified" {

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

@ -11,8 +11,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/install" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check if two instances of NoCoDB can be run" { @test "Check if two instances of NoCoDB can be run" {

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

@ -10,8 +10,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/install" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Should create SSL certificates" { @test "Should create SSL certificates" {

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

@ -9,8 +9,12 @@ setup() {
} }
teardown() { teardown() {
cd "${WORKING_DIR}/install" || exit 1 if [ -n "$SKIP_TEARDOWN" ]; then
./setup.sh return
fi
cd "${WORKING_DIR}/install" || exit 1
./setup.sh
} }
@test "Check WatchTower is enabled when specified" { @test "Check WatchTower is enabled when specified" {

4
packages/nc-gui/assets/nc-icons/arrow-up-right.svg

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.66666 11.3334L11.3333 4.66675" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.66666 4.66675H11.3333V11.3334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

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

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

After

Width:  |  Height:  |  Size: 597 B

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

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5.99992L8 1.33325L14 5.99992V13.3333C14 13.6869 13.8595 14.026 13.6095 14.2761C13.3594 14.5261 13.0203 14.6666 12.6667 14.6666H3.33333C2.97971 14.6666 2.64057 14.5261 2.39052 14.2761C2.14048 14.026 2 13.6869 2 13.3333V5.99992Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 14.6667V8H10V14.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 573 B

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

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

After

Width:  |  Height:  |  Size: 1.7 KiB

11
packages/nc-gui/assets/nc-icons/slash.svg

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="slash" clip-path="url(#clip0_311_1327)">
<path id="Vector" d="M7.99998 14.6668C11.6819 14.6668 14.6666 11.6821 14.6666 8.00016C14.6666 4.31826 11.6819 1.3335 7.99998 1.3335C4.31808 1.3335 1.33331 4.31826 1.33331 8.00016C1.33331 11.6821 4.31808 14.6668 7.99998 14.6668Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M3.28662 3.28662L12.7133 12.7133" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_311_1327">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 732 B

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

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

After

Width:  |  Height:  |  Size: 257 B

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

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

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

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

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

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

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

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

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

@ -109,7 +109,7 @@ watch(
<nuxt-link <nuxt-link
v-else-if="validEmail" v-else-if="validEmail"
no-ref no-ref
class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link" class="py-1 underline hover:opacity-75 inline-block nc-cell-field-link max-w-full"
:href="`mailto:${vModel}`" :href="`mailto:${vModel}`"
target="_blank" target="_blank"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"

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

@ -221,7 +221,9 @@ watch(isOpen, (n, _o) => {
if (!n) searchVal.value = '' if (!n) searchVal.value = ''
if (editAllowed.value) { if (editAllowed.value) {
if (n) { if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus() aselect.value?.$el?.querySelector('input')?.focus()
} }
} }

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

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

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

@ -121,7 +121,7 @@ watch(
v-else-if="isValid && !cellUrlOptions?.overlay" v-else-if="isValid && !cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel no-rel
class="py-1 z-3 underline hover:opacity-75 nc-cell-field-link" class="py-1 z-3 underline hover:opacity-75 nc-cell-field-link max-w-full"
:to="url" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"
@ -133,7 +133,7 @@ watch(
v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay" v-else-if="isValid && !disableOverlay && cellUrlOptions?.overlay"
no-prefetch no-prefetch
no-rel no-rel
class="py-1 z-3 w-full h-full text-center !no-underline hover:opacity-75 nc-cell-field-link" class="py-1 z-3 w-full h-full text-center !no-underline hover:opacity-75 nc-cell-field-link max-w-full"
:to="url" :to="url"
:target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'" :target="cellUrlOptions?.behavior === 'replace' ? undefined : '_blank'"
:tabindex="readOnly ? -1 : 0" :tabindex="readOnly ? -1 : 0"

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

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

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

@ -20,12 +20,14 @@ const { isMobileMode } = useGlobal()
const logout = async () => { const logout = async () => {
isLoggingOut.value = true isLoggingOut.value = true
try { try {
const isSsoUser = !!(user?.value as any)?.sso_client_id
await signOut(false) await signOut(false)
// No need as all stores are cleared on signout // No need as all stores are cleared on signout
// await clearWorkspaces() // await clearWorkspaces()
await navigateTo('/signin') await navigateTo(isSsoUser ? '/sso' : '/signin')
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
@ -167,6 +169,8 @@ onMounted(() => {
<NcDivider /> <NcDivider />
<DashboardSidebarEEMenuOption v-if="isEeUI" />
<nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile"> <nuxt-link v-e="['c:user:settings']" class="!no-underline" to="/account/profile">
<NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem> <NcMenuItem> <GeneralIcon icon="ncSettings" class="menu-icon" /> {{ $t('title.accountSettings') }} </NcMenuItem>
</nuxt-link> </nuxt-link>

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

@ -632,9 +632,9 @@ const onTableIdCopy = async () => {
@click="onProjectClick(base, true, true)" @click="onProjectClick(base, true, true)"
> >
<GeneralIcon <GeneralIcon
icon="chevronDown" icon="chevronRight"
class="group-hover:visible cursor-pointer transform transition-transform duration-500 rotate-270" class="group-hover:visible cursor-pointer transform transition-transform duration-200 text-[20px]"
:class="{ '!rotate-180': base.isExpanded }" :class="{ '!rotate-90': base.isExpanded }"
/> />
</NcButton> </NcButton>
</template> </template>

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

@ -395,9 +395,9 @@ const deleteTable = () => {
@click.stop="onExpand" @click.stop="onExpand"
> >
<GeneralIcon <GeneralIcon
icon="chevronDown" icon="chevronRight"
class="nc-sidebar-source-node-btns cursor-pointer transform transition-transform duration-500 !text-gray-600 rotate-270" class="nc-sidebar-source-node-btns cursor-pointer transform transition-transform duration-200 !text-gray-600 text-[20px]"
:class="{ '!rotate-180': isExpanded }" :class="{ '!rotate-90': isExpanded }"
/> />
</NcButton> </NcButton>
</div> </div>

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

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

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

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

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

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

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

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

54
packages/nc-gui/components/dlg/WorkspaceDelete.vue

@ -1,54 +0,0 @@
<script lang="ts" setup>
const props = defineProps<{
visible: boolean
workspaceId: string
}>()
const emits = defineEmits(['update:visible'])
const visible = useVModel(props, 'visible', emits)
const workspaceStore = useWorkspace()
const { deleteWorkspace: _deleteWorkspace, loadWorkspaces, navigateToWorkspace } = workspaceStore
const { workspaces, workspacesList } = storeToRefs(workspaceStore)
const { refreshCommandPalette } = useCommandPalette()
const workspace = computed(() => workspaces.value.get(props.workspaceId))
const onDelete = async () => {
if (!workspace.value) return
try {
await _deleteWorkspace(workspace.value.id!)
await loadWorkspaces()
if (!workspacesList.value?.[0]?.id) {
return await navigateToWorkspace()
}
await navigateToWorkspace(workspacesList.value?.[0]?.id)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
refreshCommandPalette()
}
}
</script>
<template>
<GeneralDeleteModal v-model:visible="visible" :entity-name="$t('objects.workspace')" :on-delete="onDelete">
<template #entity-preview>
<div v-if="workspace" class="flex flex-row items-center py-2.25 px-2.75 bg-gray-50 rounded-lg text-gray-700 mb-4">
<GeneralIcon icon="workspace" />
<div
class="capitalize text-ellipsis overflow-hidden select-none w-full pl-2.25"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ workspace.title }}
</div>
</div>
</template>
</GeneralDeleteModal>
</template>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2
packages/nc-gui/components/shared-view/Calendar.vue

@ -28,6 +28,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView) useProvideKanbanViewStore(meta, sharedView)

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

@ -32,6 +32,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView) useProvideKanbanViewStore(meta, sharedView)

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

@ -23,7 +23,7 @@ const { signedIn } = useGlobal()
const { loadProject } = useBase() const { loadProject } = useBase()
const { isLocked } = useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) const { isLocked, xWhere } = useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView) useProvideKanbanViewStore(meta, sharedView)
useProvideCalendarViewStore(meta, sharedView) useProvideCalendarViewStore(meta, sharedView)
@ -41,6 +41,9 @@ provide(IsPublicInj, ref(true))
provide(IsLockedInj, isLocked) provide(IsLockedInj, isLocked)
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideViewGroupBy(sharedView, meta, xWhere, true)
useProvideSmartsheetLtarHelpers(meta)
if (signedIn.value) { if (signedIn.value) {
try { try {

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

@ -27,6 +27,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideKanbanViewStore(meta, sharedView, true) useProvideKanbanViewStore(meta, sharedView, true)

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

@ -27,6 +27,8 @@ provide(IsPublicInj, ref(true))
useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true) useProvideViewColumns(sharedView, meta, () => reloadEventHook?.trigger(), true)
useProvideSmartsheetLtarHelpers(meta)
useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters) useProvideSmartsheetStore(sharedView, meta, true, ref([]), nestedFilters)
useProvideMapViewStore(meta, sharedView, true) useProvideMapViewStore(meta, sharedView, true)

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

@ -112,6 +112,17 @@ const syncValue = useDebounceFn(
{ maxWait: 2000 }, { maxWait: 2000 },
) )
let saveTimer: number
const updateWhenEditCompleted = () => {
if (editEnabled.value) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = window.setTimeout(updateWhenEditCompleted, 500)
} else {
emit('save')
}
}
const vModel = computed({ const vModel = computed({
get: () => { get: () => {
return props.modelValue return props.modelValue
@ -122,7 +133,9 @@ const vModel = computed({
} else if (val !== props.modelValue) { } else if (val !== props.modelValue) {
currentRow.value.rowMeta.changed = true currentRow.value.rowMeta.changed = true
emit('update:modelValue', val) emit('update:modelValue', val)
if (isAutoSaved(column.value)) { if (column.value.pk || column.value.unique) {
updateWhenEditCompleted()
} else if (isAutoSaved(column.value)) {
syncValue() syncValue()
} else if (!isManualSaved(column.value)) { } else if (!isManualSaved(column.value)) {
emit('save') emit('save')
@ -154,43 +167,10 @@ const onContextmenu = (e: MouseEvent) => {
e.stopPropagation() e.stopPropagation()
} }
} }
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
const intersectionObserver = ref<IntersectionObserver>()
const elementToObserve = ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver.value?.disconnect()
intersectionObserver.value = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver.value?.observe(elementToObserve.value!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver.value?.disconnect()
})
</script> </script>
<template> <template>
<div <div
ref="elementToObserve"
:class="[ :class="[
`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, `nc-cell-${(column?.uidt || 'default').toLowerCase()}`,
{ {
@ -214,51 +194,49 @@ onUnmounted(() => {
@keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="navigate(NavigateDir.PREV, $event)"
> >
<template v-if="column"> <template v-if="column">
<template v-if="intersected"> <LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" />
<LazyCellTextArea v-if="isTextArea(column)" v-model="vModel" :virtual="props.virtual" /> <LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" />
<LazyCellGeoData v-else-if="isGeoData(column)" v-model="vModel" /> <LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean(column, abstractType)" v-model="vModel" /> <LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellAttachment v-else-if="isAttachment(column)" v-model="vModel" :row-index="props.rowIndex" /> <LazyCellSingleSelect
<LazyCellSingleSelect v-else-if="isSingleSelect(column)"
v-else-if="isSingleSelect(column)" v-model="vModel"
v-model="vModel" :disable-option-creation="!!isEditColumnMenu"
:disable-option-creation="!!isEditColumnMenu" :row-index="props.rowIndex"
:row-index="props.rowIndex" />
/> <LazyCellMultiSelect
<LazyCellMultiSelect v-else-if="isMultiSelect(column)"
v-else-if="isMultiSelect(column)" v-model="vModel"
v-model="vModel" :disable-option-creation="!!isEditColumnMenu"
:disable-option-creation="!!isEditColumnMenu" :row-index="props.rowIndex"
:row-index="props.rowIndex" />
/> <LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" /> <LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" /> <LazyCellDateTimePicker
<LazyCellDateTimePicker v-else-if="isDateTime(column, abstractType)"
v-else-if="isDateTime(column, abstractType)" v-model="vModel"
v-model="vModel" :is-pk="isPrimaryKey(column)"
:is-pk="isPrimaryKey(column)" :is-updated-from-copy-n-paste="currentRow.rowMeta.isUpdatedFromCopyNPaste"
:is-updated-from-copy-n-paste="currentRow.rowMeta.isUpdatedFromCopyNPaste" />
/> <LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" /> <LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" /> <LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" /> <LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" />
<LazyCellEmail v-else-if="isEmail(column)" v-model="vModel" /> <LazyCellUrl v-else-if="isURL(column)" v-model="vModel" />
<LazyCellUrl v-else-if="isURL(column)" v-model="vModel" /> <LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" /> <LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" /> <LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" /> <LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" :row-index="props.rowIndex" /> <LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" /> <LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" /> <LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" /> <LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" />
<LazyCellInteger v-else-if="isInt(column, abstractType)" v-model="vModel" /> <LazyCellJson v-else-if="isJSON(column)" v-model="vModel" />
<LazyCellJson v-else-if="isJSON(column)" v-model="vModel" /> <LazyCellText v-else v-model="vModel" />
<LazyCellText v-else v-model="vModel" /> <div
<div v-if="((isPublic && readOnly && !isForm) || (isSystemColumn(column) && !isAttachment(column))) && !isTextArea(column)"
v-if="((isPublic && readOnly && !isForm) || (isSystemColumn(column) && !isAttachment(column))) && !isTextArea(column)" class="nc-locked-overlay"
class="nc-locked-overlay" />
/>
</template>
</template> </template>
</div> </div>
</template> </template>

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

@ -120,7 +120,6 @@ reloadEventHook.on(async () => {
const { fields, showAll, hideAll } = useViewColumnsOrThrow() const { fields, showAll, hideAll } = useViewColumnsOrThrow()
const { state, row } = useProvideSmartsheetRowStore( const { state, row } = useProvideSmartsheetRowStore(
meta,
ref({ ref({
row: formState.value, row: formState.value,
oldRow: {}, oldRow: {},

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

@ -1,6 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Ref } from 'vue'
import type { TableType } from 'nocodb-sdk'
import type { Row } from '#imports' import type { Row } from '#imports'
import { import {
ReloadRowDataHookInj, ReloadRowDataHookInj,
@ -10,7 +8,6 @@ import {
provide, provide,
toRef, toRef,
useProvideSmartsheetRowStore, useProvideSmartsheetRowStore,
useSmartsheetStoreOrThrow,
} from '#imports' } from '#imports'
const props = defineProps<{ const props = defineProps<{
@ -19,12 +16,7 @@ const props = defineProps<{
const currentRow = toRef(props, 'row') const currentRow = toRef(props, 'row')
const { meta } = useSmartsheetStoreOrThrow() const { isNew, state } = useProvideSmartsheetRowStore(currentRow)
const { isNew, state, syncLTARRefs, clearLTARCell, addLTARRef, cleaMMCell } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
currentRow,
)
const reloadViewDataTrigger = inject(ReloadViewDataHookInj)! const reloadViewDataTrigger = inject(ReloadViewDataHookInj)!
@ -39,13 +31,6 @@ reloadHook.on((params) => {
}) })
provide(ReloadRowDataHookInj, reloadHook) provide(ReloadRowDataHookInj, reloadHook)
defineExpose({
syncLTARRefs,
clearLTARCell,
addLTARRef,
cleaMMCell,
})
</script> </script>
<template> <template>

5
packages/nc-gui/components/smartsheet/SharedMapMarkerPopup.vue

@ -1,7 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnType, TableType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { isVirtualCol } from 'nocodb-sdk' import { isVirtualCol } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
@ -52,7 +49,7 @@ provide(ReloadRowDataHookInj, reloadViewDataHook!)
const currentRow = toRef(props, 'row') const currentRow = toRef(props, 'row')
useProvideSmartsheetRowStore(meta as Ref<TableType>, currentRow) useProvideSmartsheetRowStore(currentRow)
</script> </script>
<template> <template>

4
packages/nc-gui/components/smartsheet/TableDataCell.vue

@ -1,15 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { CellClickHookInj, CurrentCellInj, createEventHook, ref } from '#imports' import { CellClickHookInj, CurrentCellInj, createEventHook, ref } from '#imports'
const el = ref<HTMLTableDataCellElement>() const el = ref<HTMLElement>()
const cellClickHook = createEventHook() const cellClickHook = createEventHook()
provide(CellClickHookInj, cellClickHook) provide(CellClickHookInj, cellClickHook)
provide(CurrentCellInj, el) provide(CurrentCellInj, el)
defineExpose({ el })
</script> </script>
<template> <template>

61
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -62,43 +62,10 @@ function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
if (!isForm.value) e.stopImmediatePropagation() if (!isForm.value) e.stopImmediatePropagation()
} }
// Todo: move intersection logic to a separate component or a vue directive
const intersected = ref(false)
const intersectionObserver = ref<IntersectionObserver>()
const elementToObserve = ref<Element>()
// load the cell only when it is in the viewport
function initIntersectionObserver() {
intersectionObserver.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// if the cell is in the viewport, load the cell and disconnect the observer
if (entry.isIntersecting) {
intersected.value = true
intersectionObserver.value?.disconnect()
intersectionObserver.value = undefined
}
})
})
}
// observe the cell when it is mounted
onMounted(() => {
initIntersectionObserver()
intersectionObserver.value?.observe(elementToObserve.value!)
})
// disconnect the observer when the cell is unmounted
onUnmounted(() => {
intersectionObserver.value?.disconnect()
})
</script> </script>
<template> <template>
<div <div
ref="elementToObserve"
class="nc-virtual-cell w-full flex items-center" class="nc-virtual-cell w-full flex items-center"
:class="{ :class="{
'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm, 'text-right justify-end': isGrid && !isForm && isRollup(column) && !isExpandedForm,
@ -107,21 +74,19 @@ onUnmounted(() => {
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)" @keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)" @keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
> >
<template v-if="intersected"> <LazyVirtualCellLinks v-if="isLink(column)" />
<LazyVirtualCellLinks v-if="isLink(column)" /> <LazyVirtualCellHasMany v-else-if="isHm(column)" />
<LazyVirtualCellHasMany v-else-if="isHm(column)" /> <LazyVirtualCellManyToMany v-else-if="isMm(column)" />
<LazyVirtualCellManyToMany v-else-if="isMm(column)" /> <LazyVirtualCellBelongsTo v-else-if="isBt(column)" />
<LazyVirtualCellBelongsTo v-else-if="isBt(column)" /> <LazyVirtualCellOneToOne v-else-if="isOo(column)" />
<LazyVirtualCellOneToOne v-else-if="isOo(column)" /> <LazyVirtualCellRollup v-else-if="isRollup(column)" />
<LazyVirtualCellRollup v-else-if="isRollup(column)" /> <LazyVirtualCellFormula v-else-if="isFormula(column)" />
<LazyVirtualCellFormula v-else-if="isFormula(column)" /> <LazyVirtualCellQrCode v-else-if="isQrCode(column)" />
<LazyVirtualCellQrCode v-else-if="isQrCode(column)" /> <LazyVirtualCellBarcode v-else-if="isBarcode(column)" />
<LazyVirtualCellBarcode v-else-if="isBarcode(column)" /> <LazyVirtualCellCount v-else-if="isCount(column)" />
<LazyVirtualCellCount v-else-if="isCount(column)" /> <LazyVirtualCellLookup v-else-if="isLookup(column)" />
<LazyVirtualCellLookup v-else-if="isLookup(column)" /> <LazyCellReadOnlyDateTimePicker v-else-if="isCreatedOrLastModifiedTimeCol(column)" :model-value="modelValue" />
<LazyCellReadOnlyDateTimePicker v-else-if="isCreatedOrLastModifiedTimeCol(column)" :model-value="modelValue" /> <LazyCellReadOnlyUser v-else-if="isCreatedOrLastModifiedByCol(column)" :model-value="modelValue" />
<LazyCellReadOnlyUser v-else-if="isCreatedOrLastModifiedByCol(column)" :model-value="modelValue" />
</template>
</div> </div>
</template> </template>

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

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

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

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

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

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

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

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

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

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

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

@ -7,8 +7,6 @@ const props = defineProps<{
}>() }>()
const emits = defineEmits(['update:value']) const emits = defineEmits(['update:value'])
const meta = inject(MetaInj, ref())
provide(EditColumnInj, ref(true)) provide(EditColumnInj, ref(true))
const vModel = useVModel(props, 'value', emits) const vModel = useVModel(props, 'value', emits)
@ -20,7 +18,7 @@ const rowRef = ref({
}, },
}) })
useProvideSmartsheetRowStore(meta, rowRef) useProvideSmartsheetRowStore(rowRef)
const cdfValue = ref<string | null>(null) const cdfValue = ref<string | null>(null)

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

@ -69,7 +69,7 @@ const isKanban = inject(IsKanbanInj, ref(false))
const readOnly = computed(() => props.readonly) const readOnly = computed(() => props.readonly)
const { isMysql, isMssql, isXcdbBase } = useBase() const { isMysql, isMssql, isDatabricks, isXcdbBase } = useBase()
const reloadDataTrigger = inject(ReloadViewDataHookInj) const reloadDataTrigger = inject(ReloadViewDataHookInj)
@ -387,10 +387,18 @@ if (props.fromTableExplorer) {
!isVirtualCol(formState) && !isVirtualCol(formState) &&
!isAttachment(formState) && !isAttachment(formState) &&
!isMssql(meta!.source_id) && !isMssql(meta!.source_id) &&
!(isMysql(meta!.source_id) && (isJSON(formState) || isTextArea(formState))) !(isMysql(meta!.source_id) && (isJSON(formState) || isTextArea(formState))) &&
!(isDatabricks(meta!.source_id) && formState.unique)
" "
v-model:value="formState" v-model:value="formState"
/> />
<div
v-if="isDatabricks(meta!.source_id) && !formState.cdf && ![UITypes.MultiSelect, UITypes.Checkbox, UITypes.Rating, UITypes.Attachment, UITypes.Lookup, UITypes.Rollup, UITypes.Formula, UITypes.Barcode, UITypes.QrCode, UITypes.CreatedTime, UITypes.LastModifiedTime, UITypes.CreatedBy, UITypes.LastModifiedBy].includes(formState.uidt)"
class="mt-3"
>
<a-checkbox v-model:checked="formState.unique"> Set as Unique </a-checkbox>
</div>
</div> </div>
<div <div

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

@ -53,8 +53,6 @@ const refTables = computed(() => {
const filterOption = (value: string, option: { key: string }) => option.key.toLowerCase().includes(value.toLowerCase()) const filterOption = (value: string, option: { key: string }) => option.key.toLowerCase().includes(value.toLowerCase())
const isLinks = computed(() => vModel.value.uidt === UITypes.Links && vModel.value.type !== RelationTypes.ONE_TO_ONE) const isLinks = computed(() => vModel.value.uidt === UITypes.Links && vModel.value.type !== RelationTypes.ONE_TO_ONE)
const oneToOneEnabled = ref(false)
</script> </script>
<template> <template>
@ -62,9 +60,9 @@ const oneToOneEnabled = ref(false)
<div class="border-2 p-6"> <div class="border-2 p-6">
<a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type"> <a-form-item v-bind="validateInfos.type" class="nc-ltar-relation-type">
<a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type" class="!flex flex-col gap-2"> <a-radio-group v-model:value="vModel.type" name="type" v-bind="validateInfos.type" class="!flex flex-col gap-2">
<a-radio value="hm" @dblclick="oneToOneEnabled = !oneToOneEnabled">{{ $t('title.hasMany') }}</a-radio> <a-radio value="oo">{{ $t('title.oneToOne') }}</a-radio>
<a-radio value="hm">{{ $t('title.hasMany') }}</a-radio>
<a-radio value="mm">{{ $t('title.manyToMany') }}</a-radio> <a-radio value="mm">{{ $t('title.manyToMany') }}</a-radio>
<a-radio v-if="oneToOneEnabled" value="oo">{{ $t('title.oneToOne') }}</a-radio>
</a-radio-group> </a-radio-group>
</a-form-item> </a-form-item>

32
packages/nc-gui/components/smartsheet/column/RollupOptions.vue

@ -56,13 +56,16 @@ const refTables = computed(() => {
const _refTables = meta.value.columns const _refTables = meta.value.columns
.filter( .filter(
(c) => (c: ColumnType) =>
isLinksOrLTAR(c) && isLinksOrLTAR(c) &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes((c.colOptions as LinkToAnotherRecordType).type) && (c.colOptions as LinkToAnotherRecordType).type &&
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
(c.colOptions as LinkToAnotherRecordType).type as RelationTypes,
) &&
!c.system && !c.system &&
c.source_id === meta.value?.source_id, c.source_id === meta.value?.source_id,
) )
.map((c) => ({ .map((c: ColumnType) => ({
col: c.colOptions, col: c.colOptions,
column: c, column: c,
...tables.value.find((t) => t.id === (c.colOptions as any)?.fk_related_model_id), ...tables.value.find((t) => t.id === (c.colOptions as any)?.fk_related_model_id),
@ -118,16 +121,29 @@ const allFunctions = [
{ text: t('general.avgDistinct'), value: 'avgDistinct' }, { text: t('general.avgDistinct'), value: 'avgDistinct' },
] ]
const availableRollupPerColumn = computed(() => {
const fnMap: Record<string, { text: string; value: string }[]> = {}
columns.value?.forEach((column) => {
if (!column?.id) return
fnMap[column.id] = allFunctions.filter((func) => getAvailableRollupForUiType(column.uidt as UITypes).includes(func.value))
})
return fnMap
})
const filteredColumns = computed(() => {
return columns.value?.filter((column) => {
return column.id && availableRollupPerColumn.value[column.id as string]?.length
})
})
watch( watch(
() => vModel.value.fk_rollup_column_id, () => vModel.value.fk_rollup_column_id,
() => { () => {
const childFieldColumn = columns.value?.find((column: ColumnType) => column.id === vModel.value.fk_rollup_column_id) const childFieldColumn = columns.value?.find((column: ColumnType) => column.id === vModel.value.fk_rollup_column_id)
aggFunctionsList.value = allFunctions.filter((func) => aggFunctionsList.value = availableRollupPerColumn.value[childFieldColumn?.id as string] || []
getAvailableRollupForUiType(childFieldColumn?.uidt as UITypes).includes(func.value),
)
if (!aggFunctionsList.value.includes(vModel.value.rollup_function)) { if (aggFunctionsList.value.length && !aggFunctionsList.value.find((func) => func.value === vModel.value.rollup_function)) {
// when the previous roll up function was numeric type and the current child field is non-numeric // when the previous roll up function was numeric type and the current child field is non-numeric
// reset rollup function with a non-numeric type // reset rollup function with a non-numeric type
vModel.value.rollup_function = aggFunctionsList.value[0].value vModel.value.rollup_function = aggFunctionsList.value[0].value
@ -176,7 +192,7 @@ watch(
dropdown-class-name="nc-dropdown-relation-column !rounded-xl" dropdown-class-name="nc-dropdown-relation-column !rounded-xl"
@change="onDataTypeChange" @change="onDataTypeChange"
> >
<a-select-option v-for="(column, index) of columns" :key="index" :value="column.id"> <a-select-option v-for="(column, index) of filteredColumns" :key="index" :value="column.id">
<div class="flex gap-2 truncate items-center"> <div class="flex gap-2 truncate items-center">
<div class="flex items-center flex-1 truncate font-semibold"> <div class="flex items-center flex-1 truncate font-semibold">
<component :is="cellIcon(column)" :column-meta="column" /> <component :is="cellIcon(column)" :column-meta="column" />

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

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

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

@ -51,6 +51,7 @@ interface Props {
lastRow?: boolean lastRow?: boolean
closeAfterSave?: boolean closeAfterSave?: boolean
newRecordHeader?: string newRecordHeader?: string
skipReload?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -102,7 +103,7 @@ const expandedFormScrollWrapper = ref()
const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook()) const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const reloadViewDataTrigger = inject(ReloadViewDataHookInj) const reloadViewDataTrigger = inject(ReloadViewDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow() const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
@ -137,6 +138,8 @@ provide(MetaInj, meta)
const isLoading = ref(true) const isLoading = ref(true)
const isSaving = ref(false)
const { const {
commentsDrawer, commentsDrawer,
changedColumns, changedColumns,
@ -157,6 +160,8 @@ const duplicatingRowInProgress = ref(false)
useProvideSmartsheetStore(ref({}) as Ref<ViewType>, meta) useProvideSmartsheetStore(ref({}) as Ref<ViewType>, meta)
useProvideSmartsheetLtarHelpers(meta)
watch( watch(
state, state,
() => { () => {
@ -205,33 +210,48 @@ const onDuplicateRow = () => {
} }
const save = async () => { const save = async () => {
let kanbanClbk isSaving.value = true
if (activeView.value?.type === ViewTypes.KANBAN) {
kanbanClbk = (row: any, isNewRow: boolean) => { try {
addOrEditStackRow(row, isNewRow) let kanbanClbk
if (activeView.value?.type === ViewTypes.KANBAN) {
kanbanClbk = (row: any, isNewRow: boolean) => {
addOrEditStackRow(row, isNewRow)
}
} }
}
if (isNew.value) {
await _save(rowState.value, undefined, {
kanbanClbk,
})
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
} else {
await _save(undefined, undefined, {
kanbanClbk,
})
_loadRow()
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
}
isUnsavedFormExist.value = false
if (props.closeAfterSave) { if (isNew.value) {
isExpanded.value = false await _save(rowState.value, undefined, {
kanbanClbk,
})
} else {
await _save(undefined, undefined, {
kanbanClbk,
})
_loadRow()
}
if (!props.skipReload) {
reloadTrigger?.trigger()
reloadViewDataTrigger?.trigger()
}
isUnsavedFormExist.value = false
if (props.closeAfterSave) {
isExpanded.value = false
}
emits('createdRecord', _row.value.row)
} catch (e: any) {
if (isNew.value) {
message.error(`Add row failed: ${await extractSdkResponseErrorMsg(e)}`)
} else {
message.error(`${t('msg.error.rowUpdateFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
} }
emits('createdRecord', _row.value.row) isSaving.value = false
} }
const isPreventChangeModalOpen = ref(false) const isPreventChangeModalOpen = ref(false)
@ -375,15 +395,23 @@ useActiveKeyupListener(
e.stopPropagation() e.stopPropagation()
if (isNew.value) { try {
await _save(rowState.value) if (isNew.value) {
reloadHook?.trigger(null) await _save(rowState.value)
} else { reloadHook?.trigger(null)
await save() } else {
reloadHook?.trigger(null) await save()
} reloadHook?.trigger(null)
if (!saveRowAndStay.value) { }
onClose() if (!saveRowAndStay.value) {
onClose()
}
} catch (e: any) {
if (isNew.value) {
message.error(`Add row failed: ${await extractSdkResponseErrorMsg(e)}`)
} else {
message.error(`${t('msg.error.rowUpdateFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
} }
// on alt + n create new record // on alt + n create new record
} else if (e.code === 'KeyN') { } else if (e.code === 'KeyN') {
@ -410,9 +438,13 @@ useActiveKeyupListener(
okText: t('general.save'), okText: t('general.save'),
cancelText: t('labels.discard'), cancelText: t('labels.discard'),
onOk: async () => { onOk: async () => {
await _save(rowState.value) try {
reloadHook?.trigger(null) await _save(rowState.value)
addNewRow() reloadHook?.trigger(null)
addNewRow()
} catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
}
}, },
onCancel: () => { onCancel: () => {
addNewRow() addNewRow()
@ -869,6 +901,7 @@ export default {
<NcButton <NcButton
v-e="['c:row-expand:save']" v-e="['c:row-expand:save']"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist" :disabled="changedColumns.size === 0 && !isUnsavedFormExist"
:loading="isSaving"
class="nc-expand-form-save-btn !xs:(text-base)" class="nc-expand-form-save-btn !xs:(text-base)"
data-testid="nc-expanded-form-save" data-testid="nc-expanded-form-save"
type="primary" type="primary"
@ -915,7 +948,7 @@ export default {
<div class="flex flex-row justify-end gap-x-2 mt-5"> <div class="flex flex-row justify-end gap-x-2 mt-5">
<NcButton type="secondary" @click="discardPreventModal">{{ $t('labels.discard') }}</NcButton> <NcButton type="secondary" @click="discardPreventModal">{{ $t('labels.discard') }}</NcButton>
<NcButton key="submit" type="primary" label="Rename Table" loading-label="Renaming Table" @click="saveChanges"> <NcButton key="submit" type="primary" :loading="isSaving" @click="saveChanges">
{{ $t('tooltip.saveChanges') }} {{ $t('tooltip.saveChanges') }}
</NcButton> </NcButton>
</div> </div>

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

@ -37,6 +37,16 @@ const { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore())
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const _loadGroupData = async (group: Group, force?: boolean, params?: any) => {
isViewDataLoading.value = true
isPaginationLoading.value = true
await props.loadGroupData(group, force, params)
isViewDataLoading.value = false
isPaginationLoading.value = false
}
const _depth = props.depth ?? 0 const _depth = props.depth ?? 0
const wrapper = ref<HTMLElement | undefined>() const wrapper = ref<HTMLElement | undefined>()
@ -67,12 +77,12 @@ const findAndLoadSubGroup = (key: any) => {
if (key.length > 0 && vGroup.value.children) { if (key.length > 0 && vGroup.value.children) {
if (!oldActiveGroups.value.includes(key[key.length - 1])) { if (!oldActiveGroups.value.includes(key[key.length - 1])) {
const k = key[key.length - 1].replace('group-panel-', '') const k = key[key.length - 1].replace('group-panel-', '')
const grp = vGroup.value.children[k] const grp = vGroup.value.children.find((g) => `${g.key}` === k)
if (grp) { if (grp) {
if (grp.nested) { if (grp.nested) {
if (!grp.children?.length) props.loadGroups({}, grp) if (!grp.children?.length) props.loadGroups({}, grp)
} else { } else {
if (!grp.rows?.length || grp.count !== grp.rows?.length) props.loadGroupData(grp) if (!grp.rows?.length || grp.count !== grp.rows?.length) _loadGroupData(grp)
} }
} }
} }
@ -84,37 +94,35 @@ const reloadViewDataHandler = (params: void | { shouldShowLoading?: boolean | un
if (vGroup.value.nested) { if (vGroup.value.nested) {
props.loadGroups({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) }, vGroup.value) props.loadGroups({ ...(params?.offset !== undefined ? { offset: params.offset } : {}) }, vGroup.value)
} else { } else {
props.loadGroupData(vGroup.value, true, { _loadGroupData(vGroup.value, true, {
...(params?.offset !== undefined ? { offset: params.offset } : {}), ...(params?.offset !== undefined ? { offset: params.offset } : {}),
}) })
} }
} }
onMounted(async () => {
reloadViewDataHook?.on(reloadViewDataHandler)
})
onBeforeUnmount(async () => { onBeforeUnmount(async () => {
reloadViewDataHook?.off(reloadViewDataHandler) reloadViewDataHook?.off(reloadViewDataHandler)
}) })
reloadViewDataHook?.on(reloadViewDataHandler) watch([() => vGroup.value.key], async (n, o) => {
if (n !== o) {
watch( if (!vGroup.value.nested) {
[() => vGroup.value.key], await _loadGroupData(vGroup.value, true)
async (n, o) => { } else if (vGroup.value.nested) {
if (n !== o) { await props.loadGroups({}, vGroup.value)
isViewDataLoading.value = true
isPaginationLoading.value = true
if (vGroup.value.nested) {
await props.loadGroups({}, vGroup.value)
} else {
await props.loadGroupData(vGroup.value, true)
}
isViewDataLoading.value = false
isPaginationLoading.value = false
} }
}, }
{ immediate: true }, })
)
onMounted(async () => {
if (vGroup.value.root === true) {
await props.loadGroups({}, vGroup.value)
}
})
if (vGroup.value.root === true) provide(ScrollParentInj, wrapper) if (vGroup.value.root === true) provide(ScrollParentInj, wrapper)
@ -231,7 +239,7 @@ const shouldRenderCell = (column) =>
> >
<a-collapse-panel <a-collapse-panel
v-for="[i, grp] of Object.entries(vGroup?.children ?? [])" v-for="[i, grp] of Object.entries(vGroup?.children ?? [])"
:key="`group-panel-${i}`" :key="`group-panel-${grp.key}`"
class="!border-1 nc-group rounded-[12px]" class="!border-1 nc-group rounded-[12px]"
:class="{ 'mb-4': vGroup.children && +i !== vGroup.children.length - 1 }" :class="{ 'mb-4': vGroup.children && +i !== vGroup.children.length - 1 }"
:style="`background: rgb(${245 - _depth * 10}, ${245 - _depth * 10}, ${245 - _depth * 10})`" :style="`background: rgb(${245 - _depth * 10}, ${245 - _depth * 10}, ${245 - _depth * 10})`"
@ -243,7 +251,7 @@ const shouldRenderCell = (column) =>
<span role="img" aria-label="right" class="anticon anticon-right ant-collapse-arrow"> <span role="img" aria-label="right" class="anticon anticon-right ant-collapse-arrow">
<GeneralIcon <GeneralIcon
icon="chevronDown" icon="chevronDown"
:style="`${activeGroups.includes(i) ? 'transform: rotate(360deg)' : 'transform: rotate(270deg)'}`" :style="`${activeGroups.includes(grp.key) ? 'transform: rotate(360deg)' : 'transform: rotate(270deg)'}`"
></GeneralIcon> ></GeneralIcon>
</span> </span>
</div> </div>
@ -328,7 +336,7 @@ const shouldRenderCell = (column) =>
v-if="!grp.nested && grp.rows" v-if="!grp.nested && grp.rows"
:group="grp" :group="grp"
:load-groups="loadGroups" :load-groups="loadGroups"
:load-group-data="loadGroupData" :load-group-data="_loadGroupData"
:load-group-page="loadGroupPage" :load-group-page="loadGroupPage"
:group-wrapper-change-page="groupWrapperChangePage" :group-wrapper-change-page="groupWrapperChangePage"
:row-height="rowHeight" :row-height="rowHeight"
@ -345,7 +353,7 @@ const shouldRenderCell = (column) =>
v-else v-else
:group="grp" :group="grp"
:load-groups="loadGroups" :load-groups="loadGroups"
:load-group-data="loadGroupData" :load-group-data="_loadGroupData"
:load-group-page="loadGroupPage" :load-group-page="loadGroupPage"
:group-wrapper-change-page="groupWrapperChangePage" :group-wrapper-change-page="groupWrapperChangePage"
:row-height="rowHeight" :row-height="rowHeight"

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

@ -148,6 +148,7 @@ const pagination = computed(() => {
:hide-header="true" :hide-header="true"
:pagination="pagination" :pagination="pagination"
:disable-skeleton="true" :disable-skeleton="true"
:disable-virtual-y="true"
/> />
</template> </template>

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

File diff suppressed because it is too large Load Diff

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

@ -20,7 +20,7 @@ import {
ref, ref,
useSmartsheetStoreOrThrow, useSmartsheetStoreOrThrow,
useViewData, useViewData,
useViewGroupBy, useViewGroupByOrThrow,
} from '#imports' } from '#imports'
import type { Row } from '#imports' import type { Row } from '#imports'
@ -166,7 +166,7 @@ const toggleOptimisedQuery = () => {
} }
const { rootGroup, groupBy, isGroupBy, loadGroups, loadGroupData, loadGroupPage, groupWrapperChangePage, redistributeRows } = const { rootGroup, groupBy, isGroupBy, loadGroups, loadGroupData, loadGroupPage, groupWrapperChangePage, redistributeRows } =
useViewGroupBy(view, xWhere) useViewGroupByOrThrow()
const coreWrapperRef = ref<HTMLElement>() const coreWrapperRef = ref<HTMLElement>()

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

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

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

@ -56,7 +56,7 @@ const showDeleteColumnModal = ref(false)
const { gridViewCols } = useViewColumnsOrThrow() const { gridViewCols } = useViewColumnsOrThrow()
const { fieldsToGroupBy, groupByLimit } = useViewGroupBy(view) const { fieldsToGroupBy, groupByLimit } = useViewGroupByOrThrow(view)
const setAsDisplayValue = async () => { const setAsDisplayValue = async () => {
try { try {

21
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -1,5 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnReqType, ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType } from 'nocodb-sdk' import {
type ColumnReqType,
type ColumnType,
type FormulaType,
type LinkToAnotherRecordType,
type LookupType,
type RollupType,
isLinksOrLTAR,
} from 'nocodb-sdk'
import { RelationTypes, UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk' import { RelationTypes, UITypes, UITypesName, substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import { import {
ColumnInj, ColumnInj,
@ -12,6 +20,7 @@ import {
isHm, isHm,
isLookup, isLookup,
isMm, isMm,
isOo,
isRollup, isRollup,
isVirtualColRequired, isVirtualColRequired,
provide, provide,
@ -57,7 +66,7 @@ const colOptions = computed(() => column.value?.colOptions)
const tableTile = computed(() => meta?.value?.title) const tableTile = computed(() => meta?.value?.title)
const relationColumnOptions = computed<LinkToAnotherRecordType | null>(() => { const relationColumnOptions = computed<LinkToAnotherRecordType | null>(() => {
if (isMm(column.value) || isHm(column.value) || isBt(column.value)) { if (isLinksOrLTAR(column.value)) {
return column.value?.colOptions as LinkToAnotherRecordType return column.value?.colOptions as LinkToAnotherRecordType
} else if ((column?.value?.colOptions as LookupType | RollupType)?.fk_relation_column_id) { } else if ((column?.value?.colOptions as LookupType | RollupType)?.fk_relation_column_id) {
return meta?.value?.columns?.find( return meta?.value?.columns?.find(
@ -101,6 +110,8 @@ const tooltipMsg = computed(() => {
return `'${tableTile.value}' & '${relatedTableTitle.value}' ${t('labels.manyToMany')}` return `'${tableTile.value}' & '${relatedTableTitle.value}' ${t('labels.manyToMany')}`
} else if (isBt(column.value)) { } else if (isBt(column.value)) {
return `'${column?.value?.title}' ${t('labels.belongsTo')} '${relatedTableTitle.value}'` return `'${column?.value?.title}' ${t('labels.belongsTo')} '${relatedTableTitle.value}'`
} else if (isOo(column.value)) {
return `'${tableTile.value}' & '${relatedTableTitle.value}' ${t('labels.oneToOne')}`
} else if (isLookup(column.value)) { } else if (isLookup(column.value)) {
return `'${childColumn.value.title}' from '${relatedTableTitle.value}' (${childColumn.value.uidt})` return `'${childColumn.value.title}' from '${relatedTableTitle.value}' (${childColumn.value.uidt})`
} else if (isFormula(column.value)) { } else if (isFormula(column.value)) {
@ -116,6 +127,10 @@ const tooltipMsg = computed(() => {
return column?.value?.title || '' return column?.value?.title || ''
}) })
const showTooltipAlways = computed(() => {
return isLinksOrLTAR(column.value) || isFormula(column.value) || isRollup(column.value) || isLookup(column.value)
})
const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null) const columnOrder = ref<Pick<ColumnReqType, 'column_order'> | null>(null)
const columnTypeName = computed(() => { const columnTypeName = computed(() => {
@ -172,7 +187,7 @@ const openDropDown = (e: Event) => {
</NcTooltip> </NcTooltip>
<LazySmartsheetHeaderVirtualCellIcon v-else /> <LazySmartsheetHeaderVirtualCellIcon v-else />
</template> </template>
<NcTooltip placement="bottom" class="truncate name pl-1" show-on-truncate-only> <NcTooltip placement="bottom" class="truncate name pl-1" :show-on-truncate-only="!showTooltipAlways">
<template #title> <template #title>
{{ tooltipMsg }} {{ tooltipMsg }}
</template> </template>

24
packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue

@ -371,6 +371,23 @@ watch(
immediate: true, immediate: true,
}, },
) )
const isLogicalOpChangeAllowed = computed(() => {
return new Set(filters.value.slice(1).map((filter) => filter.logical_op)).size > 1
})
// when logical operation is updated, update all the siblings with the same logical operation only if it's in locked state
const onLogicalOpUpdate = async (filter: Filter, index: number) => {
if (index === 1 && filters.value.slice(2).every((siblingFilter) => siblingFilter.logical_op !== filter.logical_op)) {
await Promise.all(
filters.value.slice(2).map(async (siblingFilter, i) => {
siblingFilter.logical_op = filter.logical_op
await saveOrUpdate(siblingFilter, i + 2, false, false, true)
}),
)
}
await filterUpdateCondition(filter, index)
}
</script> </script>
<template> <template>
@ -403,6 +420,7 @@ watch(
class="min-w-20 capitalize" class="min-w-20 capitalize"
placeholder="Group op" placeholder="Group op"
dropdown-class-name="nc-dropdown-filter-logical-op-group" dropdown-class-name="nc-dropdown-filter-logical-op-group"
:disabled="i > 1 && !isLogicalOpChangeAllowed"
@click.stop @click.stop
@change="saveOrUpdate(filter, i)" @change="saveOrUpdate(filter, i)"
> >
@ -455,9 +473,9 @@ watch(
:dropdown-match-select-width="false" :dropdown-match-select-width="false"
class="h-full !min-w-20 !max-w-20 capitalize" class="h-full !min-w-20 !max-w-20 capitalize"
hide-details hide-details
:disabled="filter.readOnly" :disabled="filter.readOnly || (i > 1 && !isLogicalOpChangeAllowed)"
dropdown-class-name="nc-dropdown-filter-logical-op" dropdown-class-name="nc-dropdown-filter-logical-op"
@change="filterUpdateCondition(filter, i)" @change="onLogicalOpUpdate(filter, i)"
@click.stop @click.stop
> >
<a-select-option v-for="op of logicalOps" :key="op.value" :value="op.value"> <a-select-option v-for="op of logicalOps" :key="op.value" :value="op.value">
@ -601,7 +619,7 @@ watch(
</div> </div>
</NcButton> </NcButton>
<NcButton v-if="!webHook && nestedLevel < 5" type="text" size="small" @click.stop="addFilterGroup()"> <NcButton v-if="nestedLevel < 5" type="text" size="small" @click.stop="addFilterGroup()">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Add Filter Group --> <!-- Add Filter Group -->
<component :is="iconMap.plus" /> <component :is="iconMap.plus" />

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

@ -12,13 +12,11 @@ const emits = defineEmits(['created'])
const { isParentOpen, columns } = toRefs(props) const { isParentOpen, columns } = toRefs(props)
const activeView = inject(ActiveViewInj, ref())
const meta = inject(MetaInj, ref()) const meta = inject(MetaInj, ref())
const { showSystemFields, metaColumnById } = useViewColumnsOrThrow() const { showSystemFields, metaColumnById } = useViewColumnsOrThrow()
const { groupBy } = useViewGroupBy(activeView) const { groupBy } = useViewGroupByOrThrow()
const options = computed<ColumnType[]>( const options = computed<ColumnType[]>(
() => () =>

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

@ -54,7 +54,7 @@ const {
toggleFieldVisibility, toggleFieldVisibility,
} = useViewColumnsOrThrow() } = useViewColumnsOrThrow()
const { eventBus } = useSmartsheetStoreOrThrow() const { eventBus, isDefaultView } = useSmartsheetStoreOrThrow()
const { addUndo, defineViewScope } = useUndoRedo() const { addUndo, defineViewScope } = useUndoRedo()
@ -127,15 +127,12 @@ const onMove = async (_event: { moved: { newIndex: number; oldIndex: number } },
fields.value.map(async (field, index) => { fields.value.map(async (field, index) => {
if (field.order !== index + 1) { if (field.order !== index + 1) {
field.order = index + 1 field.order = index + 1
await saveOrUpdate(field, index, true) await saveOrUpdate(field, index, true, !!isDefaultView.value)
} }
}), }),
) )
await loadViewColumns() await loadViewColumns()
await reloadViewDataHook?.trigger({
shouldShowLoading: false,
})
$e('a:fields:reorder') $e('a:fields:reorder')
} catch (e) { } catch (e) {

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

@ -22,7 +22,7 @@ const isLocked = inject(IsLockedInj, ref(false))
const { gridViewCols, updateGridViewColumn, metaColumnById, showSystemFields } = useViewColumnsOrThrow() const { gridViewCols, updateGridViewColumn, metaColumnById, showSystemFields } = useViewColumnsOrThrow()
const { fieldsToGroupBy, groupByLimit } = useViewGroupBy(view) const { fieldsToGroupBy, groupByLimit } = useViewGroupByOrThrow()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()

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

@ -173,7 +173,7 @@ function openDeleteDialog() {
> >
<div <div
v-e="['c:breadcrumb:view-actions']" v-e="['c:breadcrumb:view-actions']"
class="truncate nc-active-view-title flex gap-0.5 items-center !hover:(bg-gray-100 text-gray-800) ml-1 pl-1 pr-0.25 rounded-md py-1 cursor-pointer" class="truncate nc-active-view-title flex gap-1 items-center !hover:(bg-gray-100 text-gray-800) ml-1 pl-1 pr-0.25 rounded-md py-1 cursor-pointer"
:class="{ :class="{
'max-w-2/5': !isSharedBase && !isMobileMode && activeView?.is_default, 'max-w-2/5': !isSharedBase && !isMobileMode && activeView?.is_default,
'max-w-3/5': !isSharedBase && !isMobileMode && !activeView?.is_default, 'max-w-3/5': !isSharedBase && !isMobileMode && !activeView?.is_default,
@ -192,7 +192,7 @@ function openDeleteDialog() {
{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }} {{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</span> </span>
</NcTooltip> </NcTooltip>
<GeneralIcon icon="arrowDown" class="ml-1" /> <GeneralIcon icon="chevronDown" class="!text-gray-500 mt-0.5" />
</div> </div>
<template #overlay> <template #overlay>
<SmartsheetToolbarViewActionMenu <SmartsheetToolbarViewActionMenu

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

@ -69,7 +69,7 @@ const openedBaseUrl = computed(() => {
</div> </div>
</NcTooltip> </NcTooltip>
</NuxtLink> </NuxtLink>
<div class="px-1.75 text-gray-500">/</div> <div class="px-1.75 text-gray-500">></div>
</template> </template>
<template v-if="!(isMobileMode && !activeView?.is_default)"> <template v-if="!(isMobileMode && !activeView?.is_default)">
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeTable?.meta?.icon" readonly size="xsmall"> <LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeTable?.meta?.icon" readonly size="xsmall">
@ -122,7 +122,7 @@ const openedBaseUrl = computed(() => {
</div> </div>
</template> </template>
<div v-if="!isMobileMode" class="pl-1.25 text-gray-500">/</div> <div v-if="!isMobileMode" class="pl-1.25 text-gray-500">></div>
<template v-if="!(isMobileMode && activeView?.is_default)"> <template v-if="!(isMobileMode && activeView?.is_default)">
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeView?.meta?.icon" readonly size="xsmall"> <LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeView?.meta?.icon" readonly size="xsmall">

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

@ -21,9 +21,11 @@ import {
provide, provide,
ref, ref,
toRef, toRef,
useExpandedFormDetachedProvider,
useMetas, useMetas,
useProvideCalendarViewStore, useProvideCalendarViewStore,
useProvideKanbanViewStore, useProvideKanbanViewStore,
useProvideSmartsheetLtarHelpers,
useProvideSmartsheetStore, useProvideSmartsheetStore,
useRoles, useRoles,
useSqlEditor, useSqlEditor,
@ -54,12 +56,10 @@ const { handleSidebarOpenOnMobileForNonViews } = useConfigStore()
const { activeTableId } = storeToRefs(useTablesStore()) const { activeTableId } = storeToRefs(useTablesStore())
const { activeView, openedViewsTab, activeViewTitleOrId } = storeToRefs(useViewsStore()) const { activeView, openedViewsTab, activeViewTitleOrId } = storeToRefs(useViewsStore())
const { isGallery, isGrid, isForm, isKanban, isLocked, isMap, isCalendar } = useProvideSmartsheetStore(activeView, meta) const { isGallery, isGrid, isForm, isKanban, isLocked, isMap, isCalendar, xWhere } = useProvideSmartsheetStore(activeView, meta)
useSqlEditor() useSqlEditor()
const { isPanelExpanded } = useExtensions()
const reloadViewDataEventHook = createEventHook() const reloadViewDataEventHook = createEventHook()
const reloadViewMetaEventHook = createEventHook<void | boolean>() const reloadViewMetaEventHook = createEventHook<void | boolean>()
@ -84,8 +84,12 @@ provide(
ReadonlyInj, ReadonlyInj,
computed(() => !isUIAllowed('dataEdit')), computed(() => !isUIAllowed('dataEdit')),
) )
useExpandedFormDetachedProvider()
useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger()) useProvideViewColumns(activeView, meta, () => reloadViewDataEventHook?.trigger())
useProvideViewGroupBy(activeView, meta, xWhere)
useProvideSmartsheetLtarHelpers(meta)
const grid = ref() const grid = ref()

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

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

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

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

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

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

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

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

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

@ -40,6 +40,8 @@ const { isUIAllowed } = useRoles()
const listItemsDlg = ref(false) const listItemsDlg = ref(false)
const isOpen = ref(false)
const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow() const { state, isNew, removeLTARRef } = useSmartsheetRowStoreOrThrow()
const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } = const { relatedTableMeta, loadRelatedTableMeta, relatedTableDisplayValueProp, relatedTableDisplayValuePropId, unlink } =
@ -53,7 +55,9 @@ const value = computed(() => {
if (cellValue?.value) { if (cellValue?.value) {
return cellValue?.value return cellValue?.value
} else if (isNew.value) { } else if (isNew.value) {
return state?.value?.[column?.value.title as string] const columnTitle = column?.value.title as string
const columnValue = state?.value?.[columnTitle]
return Array.isArray(columnValue) ? columnValue[0] : columnValue
} }
return null return null
}) })
@ -80,54 +84,63 @@ const belongsToColumn = computed(
relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined, relatedTableMeta.value?.columns?.find((c: any) => c.title === relatedTableDisplayValueProp.value) as ColumnType | undefined,
) )
const plusBtnRef = ref<HTMLElement | null>(null) watch(listItemsDlg, () => {
isOpen.value = listItemsDlg.value
watch([listItemsDlg], () => {
if (!listItemsDlg.value) {
plusBtnRef.value?.focus()
}
}) })
// When isOpen is false, ensure the listItemsDlg is also closed.
watch(
isOpen,
(next) => {
if (!next) {
listItemsDlg.value = false
}
},
{ flush: 'post' },
)
</script> </script>
<template> <template>
<div class="flex w-full chips-wrapper items-center" :class="{ active }"> <LazyVirtualCellComponentsLinkRecordDropdown v-model:is-open="isOpen">
<div class="nc-cell-field chips flex items-center flex-1"> <div class="flex w-full chips-wrapper items-center" :class="{ active }">
<template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)"> <div class="nc-cell-field chips flex items-center flex-1">
<VirtualCellComponentsItemChip <template v-if="value && (relatedTableDisplayValueProp || relatedTableDisplayValuePropId)">
:item="value" <VirtualCellComponentsItemChip
:value=" :item="value"
!Array.isArray(value) && typeof value === 'object' :value="
? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId] !Array.isArray(value) && typeof value === 'object'
: value ? value[relatedTableDisplayValueProp] ?? value[relatedTableDisplayValuePropId]
" : value
:column="belongsToColumn" "
:show-unlink-button="true" :column="belongsToColumn"
@unlink="unlinkRef(value)" :show-unlink-button="true"
@unlink="unlinkRef(value)"
/>
</template>
</div>
<div
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup"
class="flex justify-end group gap-1 min-h-[30px] items-center"
tabindex="0"
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/> />
</template> </div>
</div> </div>
<template #overlay>
<div <LazyVirtualCellComponentsUnLinkedItems
v-if="!readOnly && (isUIAllowed('dataEdit') || isForm) && !isUnderLookup" v-if="listItemsDlg"
ref="plusBtnRef" v-model="listItemsDlg"
class="flex justify-end group gap-1 min-h-[30px] items-center" :column="belongsToColumn"
tabindex="0" hide-back-btn
@keydown.enter.stop="listItemsDlg = true"
>
<GeneralIcon
:icon="addIcon"
class="select-none !text-md text-gray-700 nc-action-icon nc-plus invisible group-hover:visible group-focus:visible"
@click.stop="listItemsDlg = true"
/> />
</div> </template>
</LazyVirtualCellComponentsLinkRecordDropdown>
<LazyVirtualCellComponentsUnLinkedItems
v-if="listItemsDlg"
v-model="listItemsDlg"
:column="belongsToColumn"
@attach-record="listItemsDlg = true"
/>
</div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,181 +1,3 @@
<script lang="ts" setup>
import { ref, storeToRefs, useGlobal, useI18n, useWorkspace, watch } from '#imports'
const { signOut } = useGlobal()
const { t } = useI18n()
const { deleteWorkspace, navigateToWorkspace, updateWorkspace } = useWorkspace()
const { workspacesList, activeWorkspaceId, activeWorkspace, workspaces } = storeToRefs(useWorkspace())
const formValidator = ref()
const isConfirmed = ref(false)
const isDeleting = ref(false)
const isErrored = ref(false)
const isTitleUpdating = ref(false)
const isCancelButtonVisible = ref(false)
const form = ref({
title: '',
})
const formRules = {
title: [
{ required: true, message: t('msg.info.wsNameRequired') },
{ min: 3, message: t('msg.info.wsNameMinLength') },
{ max: 50, message: t('msg.info.wsNameMaxLength') },
],
}
const onDelete = async () => {
isDeleting.value = true
try {
await deleteWorkspace(activeWorkspaceId.value, { skipStateUpdate: true })
isConfirmed.value = false
isDeleting.value = false
// We only remove the delete workspace from the list after the api call is successful
workspaces.value.delete(activeWorkspaceId.value)
if (workspacesList.value.length > 1) {
await navigateToWorkspace(workspacesList.value[0].id)
} else {
// As signin page will clear the workspaces, we need to check if there are more than one workspace
await signOut(false)
setTimeout(() => {
window.location.href = '/'
}, 100)
}
} finally {
isDeleting.value = false
}
}
const titleChange = async () => {
const valid = await formValidator.value.validate()
if (!valid) return
if (isTitleUpdating.value) return
isTitleUpdating.value = true
isErrored.value = false
try {
await updateWorkspace(activeWorkspaceId.value, {
title: form.value.title,
})
} catch (e: any) {
console.error(e)
} finally {
isTitleUpdating.value = false
isCancelButtonVisible.value = false
}
}
watch(
() => activeWorkspace.value.title,
() => {
form.value.title = activeWorkspace.value.title
},
{
immediate: true,
},
)
watch(
() => form.value.title,
async () => {
try {
if (form.value.title !== activeWorkspace.value?.title) {
isCancelButtonVisible.value = true
} else {
isCancelButtonVisible.value = false
}
isErrored.value = !(await formValidator.value.validate())
} catch (e: any) {
isErrored.value = true
}
},
)
const onCancel = () => {
form.value.title = activeWorkspace.value?.title
}
</script>
<template> <template>
<div class="flex flex-col items-center nc-workspace-settings-settings"> <span />
<div class="item flex flex-col w-full">
<div class="font-medium text-base">{{ $t('labels.changeWsName') }}</div>
<a-form ref="formValidator" layout="vertical" no-style :model="form" class="w-full" @finish="titleChange">
<div class="text-gray-500 mt-6 mb-1.5">{{ `${t('objects.workspace')} ${t('general.name')}` }}</div>
<a-form-item name="title" :rules="formRules.title">
<a-input
v-model:value="form.title"
class="w-full !rounded-md !py-1.5"
placeholder="Workspace name"
data-testid="nc-workspace-settings-settings-rename-input"
/>
</a-form-item>
<div class="flex flex-row w-full justify-end mt-8 gap-4">
<NcButton
v-if="isCancelButtonVisible"
type="secondary"
html-type="submit"
data-testid="nc-workspace-settings-settings-rename-cancel"
@click="onCancel"
>
<template #loading> {{ $t('title.renamingWs') }} </template>
{{ $t('general.cancel') }}
</NcButton>
<NcButton
v-e="['c:workspace:settings:rename']"
type="primary"
html-type="submit"
:disabled="isErrored || (form.title && form.title === activeWorkspace.title)"
:loading="isDeleting"
data-testid="nc-workspace-settings-settings-rename-submit"
>
<template #loading> {{ $t('title.renamingWs') }} </template>
{{ $t('title.renameWs') }}
</NcButton>
</div>
</a-form>
</div>
<div class="item flex flex-col">
<div class="font-medium text-base">{{ $t('title.deleteWs') }}</div>
<div class="text-gray-500 mt-2">{{ $t('msg.info.wsDeleteDlg') }}</div>
<div class="flex flex-row mt-8 gap-x-2">
<a-checkbox v-model:checked="isConfirmed" />
<div class="flex">{{ $t('msg.info.userConfirmation') }}</div>
</div>
<div class="flex flex-row w-full justify-end mt-8">
<NcButton
v-e="['c:workspace:settings:delete']"
type="danger"
:disabled="!isConfirmed"
:loading="isDeleting"
@click="onDelete"
>
<template #loading> {{ $t('title.deletingWs') }} </template>
{{ $t('title.deleteWs') }}
</NcButton>
</div>
</div>
</div>
</template> </template>
<style lang="scss" scoped>
.item {
@apply p-6 rounded-2xl border-1 max-w-180 mt-10 min-w-100 w-full;
}
</style>

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

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

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

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

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

@ -310,7 +310,8 @@ export function useData(args: {
return updatedRowData return updatedRowData
} catch (e: any) { } catch (e: any) {
message.error(`${t('msg.error.rowUpdateFailed')} ${await extractSdkResponseErrorMsg(e)}`) toUpdate.row[property] = toUpdate.oldRow[property]
message.error(`${t('msg.error.rowUpdateFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
} finally { } finally {
if (toUpdate.rowMeta) toUpdate.rowMeta.saving = false if (toUpdate.rowMeta) toUpdate.rowMeta.saving = false
} }
@ -477,8 +478,8 @@ export function useData(args: {
try { try {
await $api.dbTableRow.nestedAdd( await $api.dbTableRow.nestedAdd(
NOCO, NOCO,
base.value.title as string, base.value.id as string,
metaValue?.title as string, metaValue?.id as string,
encodeURIComponent(rowId), encodeURIComponent(rowId),
type as RelationTypes, type as RelationTypes,
column.title as string, column.title as string,
@ -630,23 +631,25 @@ export function useData(args: {
async function deleteSelectedRows() { async function deleteSelectedRows() {
let row = formattedData.value.length let row = formattedData.value.length
let removedRowsData: Record<string, any>[] = [] const removedRowsData: Record<string, any>[] = []
let compositePrimaryKey = '' let compositePrimaryKey = ''
while (row--) { while (row--) {
const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any> const { row: rowData, rowMeta } = formattedData.value[row] as Record<string, any>
if (!rowMeta.selected) { if (!rowMeta.selected) {
continue continue
} }
if (!rowMeta.new) { if (!rowMeta.new) {
const extractedPk = extractPk(meta?.value?.columns as ColumnType[]) const extractedPk = extractPk(meta?.value?.columns as ColumnType[])
const compositePkValue = extractPkFromRow(rowObj, meta?.value?.columns as ColumnType[]) const compositePkValue = extractPkFromRow(rowData, meta?.value?.columns as ColumnType[])
const pkData = rowPkData(rowData, meta?.value?.columns as ColumnType[])
if (extractedPk && compositePkValue) { if (extractedPk && compositePkValue) {
if (!compositePrimaryKey) compositePrimaryKey = extractedPk if (!compositePrimaryKey) compositePrimaryKey = extractedPk
removedRowsData.push({ removedRowsData.push({
[compositePrimaryKey]: compositePkValue as string, [compositePrimaryKey]: compositePkValue as string,
pkData,
row: clone(formattedData.value[row]) as Row, row: clone(formattedData.value[row]) as Row,
rowIndex: row as number, rowIndex: row as number,
}) })
@ -656,6 +659,8 @@ export function useData(args: {
if (!removedRowsData.length) return if (!removedRowsData.length) return
isPaginationLoading.value = true
const { list } = await $api.dbTableRow.list(NOCO, base?.value.id as string, meta.value?.id as string, { const { list } = await $api.dbTableRow.list(NOCO, base?.value.id as string, meta.value?.id as string, {
pks: removedRowsData.map((row) => row[compositePrimaryKey]).join(','), pks: removedRowsData.map((row) => row[compositePrimaryKey]).join(','),
}) })
@ -670,32 +675,22 @@ export function useData(args: {
rowObj.row = clone(fullRecord) rowObj.row = clone(fullRecord)
} }
const removedRowIds: Record<string, any>[] = await bulkDeleteRows( await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
)
if (Array.isArray(removedRowIds)) {
const removedRowsDataSet = new Set(removedRowIds.map((row) => row[compositePrimaryKey]))
removedRowsData = removedRowsData.filter((row) => removedRowsDataSet.has(row[compositePrimaryKey] as string))
const rowIndexesSet = new Set(removedRowsData.map((row) => row.rowIndex))
formattedData.value = formattedData.value.filter((_, index) => rowIndexesSet.has(index))
} else {
removedRowsData = []
}
} catch (e: any) { } catch (e: any) {
return message.error(`${t('msg.error.deleteRowFailed')}: ${await extractSdkResponseErrorMsg(e)}`) return message.error(`${t('msg.error.deleteRowFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
} }
if (!removedRowsData.length) return if (!removedRowsData.length) {
isPaginationLoading.value = false
return
}
addUndo({ addUndo({
redo: { redo: {
fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[], compositePrimaryKey: string) { fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[]) {
const removedRowIds = await bulkDeleteRows( isPaginationLoading.value = true
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
) const removedRowIds = await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
if (Array.isArray(removedRowIds)) { if (Array.isArray(removedRowIds)) {
for (const { row } of removedRowsData) { for (const { row } of removedRowsData) {
@ -706,9 +701,11 @@ export function useData(args: {
} }
} }
await callbacks?.syncCount?.()
await callbacks?.syncPagination?.() await callbacks?.syncPagination?.()
await callbacks?.globalCallback?.()
}, },
args: [removedRowsData, compositePrimaryKey], args: [removedRowsData],
}, },
undo: { undo: {
fn: async function undo( fn: async function undo(
@ -764,22 +761,24 @@ export function useData(args: {
// plus one because we want to include the end row // plus one because we want to include the end row
let row = start + 1 let row = start + 1
let removedRowsData: Record<string, any>[] = [] const removedRowsData: Record<string, any>[] = []
let compositePrimaryKey = '' let compositePrimaryKey = ''
while (row--) { while (row--) {
try { try {
const { row: rowObj, rowMeta } = formattedData.value[row] as Record<string, any> const { row: rowData, rowMeta } = formattedData.value[row] as Record<string, any>
if (!rowMeta.new) { if (!rowMeta.new) {
const extractedPk = extractPk(meta?.value?.columns as ColumnType[]) const extractedPk = extractPk(meta?.value?.columns as ColumnType[])
const compositePkValue = extractPkFromRow(rowObj, meta?.value?.columns as ColumnType[]) const compositePkValue = extractPkFromRow(rowData, meta?.value?.columns as ColumnType[])
const pkData = rowPkData(rowData, meta?.value?.columns as ColumnType[])
if (extractedPk && compositePkValue) { if (extractedPk && compositePkValue) {
if (!compositePrimaryKey) compositePrimaryKey = extractedPk if (!compositePrimaryKey) compositePrimaryKey = extractedPk
removedRowsData.push({ removedRowsData.push({
[compositePrimaryKey]: compositePkValue as string, [compositePrimaryKey]: compositePkValue as string,
pkData,
row: clone(formattedData.value[row]) as Row, row: clone(formattedData.value[row]) as Row,
rowIndex: row as number, rowIndex: row as number,
}) })
@ -794,6 +793,8 @@ export function useData(args: {
if (!removedRowsData.length) return if (!removedRowsData.length) return
isPaginationLoading.value = true
const { list } = await $api.dbTableRow.list(NOCO, base?.value.id as string, meta.value?.id as string, { const { list } = await $api.dbTableRow.list(NOCO, base?.value.id as string, meta.value?.id as string, {
pks: removedRowsData.map((row) => row[compositePrimaryKey]).join(','), pks: removedRowsData.map((row) => row[compositePrimaryKey]).join(','),
}) })
@ -808,32 +809,22 @@ export function useData(args: {
rowObj.row = clone(fullRecord) rowObj.row = clone(fullRecord)
} }
const removedRowIds: Record<string, any>[] = await bulkDeleteRows( await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
)
if (Array.isArray(removedRowIds)) {
const removedRowsDataSet = new Set(removedRowIds.map((row) => row[compositePrimaryKey]))
removedRowsData = removedRowsData.filter((row) => removedRowsDataSet.has(row[compositePrimaryKey] as string))
const rowIndexesSet = new Set(removedRowsData.map((row) => row.rowIndex))
formattedData.value = formattedData.value.filter((_, index) => rowIndexesSet.has(index))
} else {
removedRowsData = []
}
} catch (e: any) { } catch (e: any) {
return message.error(`${t('msg.error.deleteRowFailed')}: ${await extractSdkResponseErrorMsg(e)}`) return message.error(`${t('msg.error.deleteRowFailed')}: ${await extractSdkResponseErrorMsg(e)}`)
} }
if (!removedRowsData.length) return if (!removedRowsData.length) {
isPaginationLoading.value = false
return
}
addUndo({ addUndo({
redo: { redo: {
fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[], compositePrimaryKey: string) { fn: async function redo(this: UndoRedoAction, removedRowsData: Record<string, any>[]) {
const removedRowIds = await bulkDeleteRows( isPaginationLoading.value = true
removedRowsData.map((row) => ({ [compositePrimaryKey]: row[compositePrimaryKey] as string })),
) const removedRowIds = await bulkDeleteRows(removedRowsData.map((row) => row.pkData))
if (Array.isArray(removedRowIds)) { if (Array.isArray(removedRowIds)) {
for (const { row } of removedRowsData) { for (const { row } of removedRowsData) {
@ -844,9 +835,11 @@ export function useData(args: {
} }
} }
await callbacks?.syncCount?.()
await callbacks?.syncPagination?.() await callbacks?.syncPagination?.()
await callbacks?.globalCallback?.()
}, },
args: [removedRowsData, compositePrimaryKey], args: [removedRowsData],
}, },
undo: { undo: {
fn: async function undo( fn: async function undo(
@ -898,19 +891,14 @@ export function useData(args: {
rows: Record<string, string>[], rows: Record<string, string>[],
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {}, { metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
) { ) {
isPaginationLoading.value = true
try { try {
const bulkDeletedRowsData = await $api.dbDataTableRow.delete(metaValue?.id as string, rows.length === 1 ? rows[0] : rows, { const bulkDeletedRowsData = await $api.dbDataTableRow.delete(metaValue?.id as string, rows.length === 1 ? rows[0] : rows, {
viewId: viewMetaValue?.id as string, viewId: viewMetaValue?.id as string,
}) })
await callbacks?.syncCount?.()
return rows.length === 1 && bulkDeletedRowsData ? [bulkDeletedRowsData] : bulkDeletedRowsData return rows.length === 1 && bulkDeletedRowsData ? [bulkDeletedRowsData] : bulkDeletedRowsData
} catch (error: any) { } catch (error: any) {
message.error(await extractSdkResponseErrorMsg(error)) message.error(await extractSdkResponseErrorMsg(error))
} finally {
await callbacks?.globalCallback?.()
isPaginationLoading.value = false
} }
} }

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

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

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

Loading…
Cancel
Save