Browse Source

Merge branch 'develop' into feat/yyyy-mm

pull/6870/head
աɨռɢӄաօռɢ 9 months ago
parent
commit
4540a2da62
  1. 2
      packages/nc-gui/assets/nc-icons/check.svg
  2. 4
      packages/nc-gui/assets/nc-icons/lock.svg
  3. 6
      packages/nc-gui/assets/nc-icons/sort.svg
  4. 9
      packages/nc-gui/assets/style.scss
  5. 2
      packages/nc-gui/components/account/License.vue
  6. 2
      packages/nc-gui/components/account/UserList.vue
  7. 11
      packages/nc-gui/components/cell/DatePicker.vue
  8. 11
      packages/nc-gui/components/cell/DateTimePicker.vue
  9. 1
      packages/nc-gui/components/cell/Email.vue
  10. 4
      packages/nc-gui/components/cell/GeoData.vue
  11. 8
      packages/nc-gui/components/cell/MultiSelect.vue
  12. 8
      packages/nc-gui/components/cell/PhoneNumber.vue
  13. 4
      packages/nc-gui/components/cell/SingleSelect.vue
  14. 10
      packages/nc-gui/components/cell/TextArea.vue
  15. 8
      packages/nc-gui/components/cell/TimePicker.vue
  16. 8
      packages/nc-gui/components/cell/YearPicker.vue
  17. 32
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  18. 1
      packages/nc-gui/components/dashboard/TreeView/AddNewTableNode.vue
  19. 2
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  20. 3
      packages/nc-gui/components/dashboard/TreeView/TableList.vue
  21. 3
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  22. 1
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  23. 75
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  24. 3
      packages/nc-gui/components/dashboard/TreeView/index.vue
  25. 63
      packages/nc-gui/components/dashboard/View.vue
  26. 9
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  27. 51
      packages/nc-gui/components/dashboard/settings/UIAcl.vue
  28. 9
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  29. 8
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  30. 10
      packages/nc-gui/components/dlg/AirtableImport.vue
  31. 140
      packages/nc-gui/components/dlg/ColumnDuplicate.vue
  32. 2
      packages/nc-gui/components/dlg/QuickImport.vue
  33. 4
      packages/nc-gui/components/dlg/ViewCreate.vue
  34. 37
      packages/nc-gui/components/general/CopyUrl.vue
  35. 15
      packages/nc-gui/components/general/DeleteModal.vue
  36. 2
      packages/nc-gui/components/general/JoinCloud.vue
  37. 2
      packages/nc-gui/components/general/Share.vue
  38. 2
      packages/nc-gui/components/general/ShareProject.vue
  39. 2
      packages/nc-gui/components/general/Social.vue
  40. 1
      packages/nc-gui/components/general/language/Menu.vue
  41. 10
      packages/nc-gui/components/nc/Dropdown.vue
  42. 1
      packages/nc-gui/components/smartsheet/ApiSnippet.vue
  43. 8
      packages/nc-gui/components/smartsheet/Form.vue
  44. 11
      packages/nc-gui/components/smartsheet/Row.vue
  45. 7
      packages/nc-gui/components/smartsheet/Toolbar.vue
  46. 6
      packages/nc-gui/components/smartsheet/column/DecimalOptions.vue
  47. 7
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  48. 133
      packages/nc-gui/components/smartsheet/details/Fields.vue
  49. 15
      packages/nc-gui/components/smartsheet/expanded-form/Comments.vue
  50. 38
      packages/nc-gui/components/smartsheet/expanded-form/index.vue
  51. 101
      packages/nc-gui/components/smartsheet/grid/Table.vue
  52. 103
      packages/nc-gui/components/smartsheet/header/Menu.vue
  53. 46
      packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue
  54. 34
      packages/nc-gui/components/smartsheet/toolbar/LockType.vue
  55. 207
      packages/nc-gui/components/smartsheet/toolbar/OpenedViewAction.vue
  56. 8
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  57. 2
      packages/nc-gui/components/smartsheet/toolbar/SortListMenu.vue
  58. 317
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  59. 37
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  60. 2
      packages/nc-gui/components/virtual-cell/Links.vue
  61. 16
      packages/nc-gui/components/virtual-cell/QrCode.vue
  62. 8
      packages/nc-gui/components/virtual-cell/barcode/Barcode.vue
  63. 42
      packages/nc-gui/components/virtual-cell/barcode/JsBarcodeWrapper.vue
  64. 8
      packages/nc-gui/components/virtual-cell/components/Header.vue
  65. 8
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  66. 67
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  67. 2
      packages/nc-gui/components/webhook/CallLog.vue
  68. 1
      packages/nc-gui/components/webhook/Drawer.vue
  69. 2
      packages/nc-gui/components/webhook/Editor.vue
  70. 7
      packages/nc-gui/components/workspace/Menu.vue
  71. 2
      packages/nc-gui/components/workspace/ProjectList.vue
  72. 8
      packages/nc-gui/composables/useColumnCreateStore.ts
  73. 2
      packages/nc-gui/composables/useData.ts
  74. 5
      packages/nc-gui/composables/useExpandedFormStore.ts
  75. 3
      packages/nc-gui/composables/useGlobal/actions.ts
  76. 2
      packages/nc-gui/composables/useGlobal/index.ts
  77. 1
      packages/nc-gui/composables/useGlobal/state.ts
  78. 5
      packages/nc-gui/composables/useGlobal/types.ts
  79. 2
      packages/nc-gui/composables/useMultiSelect/index.ts
  80. 22
      packages/nc-gui/composables/useSmartsheetStore.ts
  81. 30
      packages/nc-gui/composables/useTableNew.ts
  82. 42
      packages/nc-gui/composables/useViewGroupBy.ts
  83. 2
      packages/nc-gui/helpers/parsers/JSONUrlTemplateAdapter.ts
  84. 38
      packages/nc-gui/lang/en.json
  85. 6
      packages/nc-gui/middleware/auth.global.ts
  86. 2
      packages/nc-gui/package.json
  87. 2
      packages/nc-gui/pages/account/index.vue
  88. 2
      packages/nc-gui/pages/profile/[[username]].vue
  89. 2
      packages/nc-gui/pages/signup/[[token]].vue
  90. 7
      packages/nc-gui/plugins/a.dayjs.ts
  91. 2
      packages/nc-gui/plugins/state.ts
  92. 4
      packages/nc-gui/store/config.ts
  93. 11
      packages/nc-gui/store/sidebar.ts
  94. 10
      packages/nc-gui/store/views.ts
  95. 69
      packages/nc-gui/utils/svgToPng.ts
  96. 3
      packages/nc-gui/utils/urlUtils.ts
  97. 2
      packages/noco-docs/docs/020.getting-started/050.self-hosted/020.environment-variables.md
  98. 48
      packages/nocodb-sdk/src/lib/Api.ts
  99. 85
      packages/nocodb/src/Noco.ts
  100. 11
      packages/nocodb/src/controllers/api-tokens.controller.ts
  101. Some files were not shown because too many files have changed in this diff Show More

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

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

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 280 B

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

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

Before

Width:  |  Height:  |  Size: 725 B

After

Width:  |  Height:  |  Size: 735 B

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

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

Before

Width:  |  Height:  |  Size: 458 B

After

Width:  |  Height:  |  Size: 473 B

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

@ -236,7 +236,7 @@ a {
} }
.nc-base-menu-item { .nc-base-menu-item {
@apply cursor-pointer flex items-center gap-2 py-2 after:(content-[''] absolute top-0 left-0 bottom-0 right-0 w-full h-full bg-current opacity-0 transition transition-opacity duration-100) hover:(after:(opacity-5)); @apply cursor-pointer flex items-center gap-2 py-2;
// &:hover { // &:hover {
// .nc-icon { // .nc-icon {
@ -479,6 +479,9 @@ a {
.nc-toolbar-btn { .nc-toolbar-btn {
@apply !shadow-none rounded hover:(ring-1 ring-gray-200 ring-opacity-100 bg-gray-100 !text-gray-800) focus:(ring-1 ring-gray-300 ring-opacity-100 !text-gray-800 bg-gray-100) text-gray-600 text-xs font-medium px-2 border-0; @apply !shadow-none rounded hover:(ring-1 ring-gray-200 ring-opacity-100 bg-gray-100 !text-gray-800) focus:(ring-1 ring-gray-300 ring-opacity-100 !text-gray-800 bg-gray-100) text-gray-600 text-xs font-medium px-2 border-0;
} }
.nc-toolbar-btn[disabled] {
@apply !text-gray-400 !cursor-not-allowed !hover:ring-0;
}
.nc-warning-info { .nc-warning-info {
@apply !shadow-none rounded ring-1 ring-red-600; @apply !shadow-none rounded ring-1 ring-red-600;
@ -675,3 +678,7 @@ input[type='number'] {
@apply xs:(visible opacity-100 !text-gray-500) @apply xs:(visible opacity-100 !text-gray-500)
} }
} }
.ant-message-notice-content {
@apply !rounded-md;
}

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

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

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

@ -157,7 +157,7 @@ const openDeleteModal = (user: UserType) => {
<template> <template>
<div data-testid="nc-super-user-list" class="h-full"> <div data-testid="nc-super-user-list" class="h-full">
<div class="max-w-195 mx-auto h-full"> <div class="max-w-195 mx-auto h-full">
<div class="text-2xl text-left font-weight-bold mb-4" data-rec="true">{{ $t('title.userManagement') }}</div> <div class="text-2xl text-left font-weight-bold mb-4" data-rec="true">{{ $t('title.userMgmt') }}</div>
<div class="py-2 flex gap-4 items-center justify-between"> <div class="py-2 flex gap-4 items-center justify-between">
<a-input v-model:value="searchText" class="!max-w-90 !rounded-md" placeholder="Search members" @change="loadUsers()"> <a-input v-model:value="searchText" class="!max-w-90 !rounded-md" placeholder="Search members" @change="loadUsers()">
<template #prefix> <template #prefix>

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

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

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

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

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

@ -86,6 +86,7 @@ watch(
<nuxt-link <nuxt-link
v-else-if="validEmail" v-else-if="validEmail"
no-ref
class="text-sm underline hover:opacity-75 inline-block" class="text-sm underline hover:opacity-75 inline-block"
:href="`mailto:${vModel}`" :href="`mailto:${vModel}`"
target="_blank" target="_blank"

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

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

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

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

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

@ -76,7 +76,13 @@ watch(
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span> <span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`tel:${vModel}`" target="_blank"> <a
v-else-if="validEmail"
class="text-sm underline hover:opacity-75"
:href="`tel:${vModel}`"
target="_blank"
rel="noopener noreferrer"
>
<LazyCellClampedText :value="vModel" :lines="rowHeight" /> <LazyCellClampedText :value="vModel" :lines="rowHeight" />
</a> </a>

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

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

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

@ -106,7 +106,15 @@ onClickOutside(inputWrapperRef, (e) => {
<span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span> <span v-else-if="vModel === null && showNull" class="nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText v-else-if="rowHeight" :value="vModel" :lines="rowHeight" class="mr-7" /> <LazyCellClampedText
v-else-if="rowHeight"
:value="vModel"
:lines="rowHeight"
class="mr-7 nc-text-area-clamped-text"
:style="{
'word-break': 'break-word',
}"
/>
<span v-else>{{ vModel }}</span> <span v-else>{{ vModel }}</span>

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

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

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

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

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

@ -107,13 +107,25 @@ onMounted(() => {
</NcMenuItem> </NcMenuItem>
</template> </template>
<NcDivider /> <NcDivider />
<a v-e="['c:nocodb:discord']" href="https://discord.gg/5RgZmkW" target="_blank" class="!underline-transparent"> <a
v-e="['c:nocodb:discord']"
href="https://discord.gg/5RgZmkW"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper"> <NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="discord" /> <GeneralIcon class="social-icon" icon="discord" />
<span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span> <span class="menu-btn"> {{ $t('labels.community.joinDiscord') }} </span>
</NcMenuItem> </NcMenuItem>
</a> </a>
<a v-e="['c:nocodb:reddit']" href="https://www.reddit.com/r/NocoDB" target="_blank" class="!underline-transparent"> <a
v-e="['c:nocodb:reddit']"
href="https://www.reddit.com/r/NocoDB"
target="_blank"
class="!underline-transparent"
rel="noopener noreferrer"
>
<NcMenuItem class="social-icon-wrapper"> <NcMenuItem class="social-icon-wrapper">
<GeneralIcon class="social-icon" icon="reddit" /> <GeneralIcon class="social-icon" icon="reddit" />
<span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span> <span class="menu-btn"> {{ $t('labels.community.joinReddit') }} </span>
@ -148,14 +160,26 @@ onMounted(() => {
<template v-if="!isMobileMode"> <template v-if="!isMobileMode">
<NcDivider /> <NcDivider />
<a v-e="['c:nocodb:forum-open']" href="https://community.nocodb.com" target="_blank" class="!underline-transparent"> <a
v-e="['c:nocodb:forum-open']"
href="https://community.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem> <NcMenuItem>
<GeneralIcon icon="help" class="menu-icon mt-0.5" /> <GeneralIcon icon="help" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.forum') }} </span> <span class="menu-btn"> {{ $t('title.forum') }} </span>
</NcMenuItem> </NcMenuItem>
</a> </a>
<a v-e="['c:nocodb:docs-open']" href="https://docs.nocodb.com" target="_blank" class="!underline-transparent"> <a
v-e="['c:nocodb:docs-open']"
href="https://docs.nocodb.com"
target="_blank"
class="!underline-transparent"
rel="noopener"
>
<NcMenuItem> <NcMenuItem>
<GeneralIcon icon="doc" class="menu-icon mt-0.5" /> <GeneralIcon icon="doc" class="menu-icon mt-0.5" />
<span class="menu-btn"> {{ $t('title.docs') }} </span> <span class="menu-btn"> {{ $t('title.docs') }} </span>

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

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

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

@ -26,6 +26,7 @@ import {
useDialog, useDialog,
useGlobal, useGlobal,
useI18n, useI18n,
useNuxtApp,
useRoles, useRoles,
useRouter, useRouter,
useTablesStore, useTablesStore,
@ -33,7 +34,6 @@ import {
useToggle, useToggle,
} from '#imports' } from '#imports'
import type { NcProject } from '#imports' import type { NcProject } from '#imports'
import { useNuxtApp } from '#app'
const indicator = h(LoadingOutlined, { const indicator = h(LoadingOutlined, {
class: '!text-gray-400', class: '!text-gray-400',

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

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

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

@ -4,8 +4,7 @@ import { toRef } from '@vue/reactivity'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useNuxtApp } from '#app' import { ProjectRoleInj, TreeViewInj, useNuxtApp, useRoles, useTabs } from '#imports'
import { ProjectRoleInj, TreeViewInj, useRoles, useTabs } from '#imports'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{

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

@ -420,6 +420,7 @@ function onOpenModal({
:key="view.id" :key="view.id"
:view="view" :view="view"
:on-validate="validate" :on-validate="validate"
:table="table"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100" class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
:class="{ :class="{
'bg-gray-200': isMarked === view.id, 'bg-gray-200': isMarked === view.id,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -330,6 +330,7 @@ onMounted(async () => {
href="https://docs.nocodb.com/bases/import-base-from-airtable#get-airtable-credentials" href="https://docs.nocodb.com/bases/import-base-from-airtable#get-airtable-credentials"
class="prose-sm underline text-grey text-xs" class="prose-sm underline text-grey text-xs"
target="_blank" target="_blank"
rel="noopener"
> >
{{ $t('msg.info.airtable.credentials') }} {{ $t('msg.info.airtable.credentials') }}
</a> </a>
@ -414,7 +415,7 @@ onMounted(async () => {
<!-- Questions / Help - Reach out here --> <!-- Questions / Help - Reach out here -->
<div> <div>
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank"> <a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank" rel="noopener noreferrer">
{{ $t('general.questions') }} / {{ $t('general.help') }} - {{ $t('general.reachOut') }}</a {{ $t('general.questions') }} / {{ $t('general.help') }} - {{ $t('general.reachOut') }}</a
> >
@ -422,7 +423,12 @@ onMounted(async () => {
<!-- This feature is currently in beta and more information can be found here --> <!-- This feature is currently in beta and more information can be found here -->
<div> <div>
{{ $t('general.betaNote') }} {{ $t('general.betaNote') }}
<a class="prose-sm" href="https://github.com/nocodb/nocodb/discussions/2122" target="_blank"> <a
class="prose-sm"
href="https://github.com/nocodb/nocodb/discussions/2122"
target="_blank"
rel="noopener noreferrer"
>
{{ $t('general.moreInfo') }} {{ $t('general.moreInfo') }}
</a> </a>
. .

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

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

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

@ -31,12 +31,12 @@ import {
useBase, useBase,
useGlobal, useGlobal,
useI18n, useI18n,
useNuxtApp,
useVModel, useVModel,
} from '#imports' } from '#imports'
// import worker script according to the doc of Vite // import worker script according to the doc of Vite
import importWorkerUrl from '~/workers/importWorker?worker&url' import importWorkerUrl from '~/workers/importWorker?worker&url'
import { useNuxtApp } from '#app'
interface Props { interface Props {
modelValue: boolean modelValue: boolean

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

@ -308,7 +308,7 @@ onMounted(async () => {
<NcSelect <NcSelect
v-model:value="form.fk_grp_col_id" v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select" class="w-full nc-kanban-grouping-field-select"
:disabled="groupingFieldColumnId || isMetaLoading" :disabled="isMetaLoading"
:loading="isMetaLoading" :loading="isMetaLoading"
:options="viewSelectFieldOptions" :options="viewSelectFieldOptions"
:placeholder="$t('placeholder.selectGroupField')" :placeholder="$t('placeholder.selectGroupField')"
@ -325,7 +325,7 @@ onMounted(async () => {
v-model:value="form.fk_geo_data_col_id" v-model:value="form.fk_geo_data_col_id"
class="w-full" class="w-full"
:options="viewSelectFieldOptions" :options="viewSelectFieldOptions"
:disabled="groupingFieldColumnId || isMetaLoading" :disabled="isMetaLoading"
:loading="isMetaLoading" :loading="isMetaLoading"
:placeholder="$t('placeholder.selectGeoField')" :placeholder="$t('placeholder.selectGeoField')"
:not-found-content="$t('placeholder.selectGeoFieldNotFound')" :not-found-content="$t('placeholder.selectGeoFieldNotFound')"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -48,6 +48,8 @@ const visibilityOps = ref<fieldsVisibilityOps[]>([])
const fieldsListWrapperDomRef = ref<HTMLElement>() const fieldsListWrapperDomRef = ref<HTMLElement>()
const { copy } = useClipboard()
const { fields: viewFields, toggleFieldVisibility, loadViewColumns, isViewColumnsLoading } = useViewColumnsOrThrow() const { fields: viewFields, toggleFieldVisibility, loadViewColumns, isViewColumnsLoading } = useViewColumnsOrThrow()
const loading = ref(false) const loading = ref(false)
@ -56,6 +58,8 @@ const columnsHash = ref<string>()
const newFields = ref<TableExplorerColumn[]>([]) const newFields = ref<TableExplorerColumn[]>([])
const isFieldIdCopied = ref(false)
const compareCols = (a?: TableExplorerColumn, b?: TableExplorerColumn) => { const compareCols = (a?: TableExplorerColumn, b?: TableExplorerColumn) => {
if (a?.id && b?.id) { if (a?.id && b?.id) {
return a.id === b.id return a.id === b.id
@ -646,6 +650,12 @@ onKeyDown('ArrowRight', () => {
} }
}) })
const onClickCopyFieldUrl = async (field: ColumnType) => {
await copy(field.id!)
isFieldIdCopied.value = true
}
const keys = useMagicKeys() const keys = useMagicKeys()
whenever(keys.meta_s, () => { whenever(keys.meta_s, () => {
@ -673,6 +683,12 @@ onMounted(async () => {
columnsHash.value = (await $api.dbTableColumn.hash(meta.value.id)).hash columnsHash.value = (await $api.dbTableColumn.hash(meta.value.id)).hash
} }
}) })
const onFieldOptionUpdate = () => {
setTimeout(() => {
isFieldIdCopied.value = false
}, 200)
}
</script> </script>
<template> <template>
@ -818,11 +834,50 @@ onMounted(async () => {
Restore Restore
</div> </div>
</NcButton> </NcButton>
<NcDropdown v-else :trigger="['click']" overlay-class-name="nc-dropdown-table-explorer" @click.stop> <NcDropdown
<GeneralIcon icon="threeDotVertical" class="no-action opacity-0 group-hover:(opacity-100) text-gray-500" /> v-else
:trigger="['click']"
overlay-class-name="nc-dropdown-table-explorer"
@update:visible="onFieldOptionUpdate"
@click.stop
>
<NcButton
size="xsmall"
type="text"
class="!opacity-0 !group-hover:(opacity-100)"
:class="{
'!hover:(text-brand-700 bg-brand-100) !group-hover:(text-brand-500)': compareCols(field, activeField),
'!hover:(text-gray-700 bg-gray-200) !group-hover:(text-gray-500)': !compareCols(field, activeField),
}"
>
<GeneralIcon icon="threeDotVertical" class="no-action text-inherit" />
</NcButton>
<template #overlay> <template #overlay>
<NcMenu> <NcMenu style="padding-top: 0.45rem !important">
<template v-if="fieldStatus(field) !== 'add'">
<NcTooltip placement="top">
<template #title>{{ $t('msg.clickToCopyFieldId') }}</template>
<div
class="flex flex-row px-3 py-2 w-46 justify-between items-center group hover:bg-gray-100 cursor-pointer"
@click="onClickCopyFieldUrl(field)"
>
<div class="flex flex-row items-baseline gap-x-1 font-bold text-xs">
<div class="text-gray-600">{{ $t('labels.idColon') }}</div>
<div class="flex flex-row text-gray-600 text-xs">
{{ field.id }}
</div>
</div>
<NcButton size="xsmall" type="secondary" class="!group-hover:bg-gray-100">
<GeneralIcon v-if="isFieldIdCopied" icon="check" />
<GeneralIcon v-else icon="copy" />
</NcButton>
</div>
</NcTooltip>
<a-menu-divider class="my-1.5" />
</template>
<NcMenuItem key="table-explorer-duplicate" @click="duplicateField(field)"> <NcMenuItem key="table-explorer-duplicate" @click="duplicateField(field)">
<Icon class="iconify text-gray-800" icon="lucide:copy" /><span>Duplicate</span> <Icon class="iconify text-gray-800" icon="lucide:copy" /><span>Duplicate</span>
</NcMenuItem> </NcMenuItem>
@ -833,11 +888,11 @@ onMounted(async () => {
<Icon class="iconify text-gray-800" icon="lucide:arrow-down" /><span>Insert below</span> <Icon class="iconify text-gray-800" icon="lucide:arrow-down" /><span>Insert below</span>
</NcMenuItem> </NcMenuItem>
<a-menu-divider class="my-1" /> <a-menu-divider class="my-1.5" />
<NcMenuItem key="table-explorer-delete" class="!hover:bg-red-50" @click="onFieldDelete(field)"> <NcMenuItem key="table-explorer-delete" class="!hover:bg-red-50" @click="onFieldDelete(field)">
<div class="text-red-500"> <div class="text-red-500">
<GeneralIcon icon="delete" class="group-hover:text-accent" /> <GeneralIcon icon="delete" class="group-hover:text-accent -ml-0.25 -mt-0.75 mr-0.5" />
Delete Delete
</div> </div>
</NcMenuItem> </NcMenuItem>
@ -915,6 +970,55 @@ onMounted(async () => {
Restore Restore
</div> </div>
</NcButton> </NcButton>
<NcDropdown
v-else
:trigger="['click']"
overlay-class-name="nc-dropdown-table-explorer-display-column"
@update:visible="onFieldOptionUpdate"
@click.stop
>
<NcButton
size="xsmall"
type="text"
class="!opacity-0 !group-hover:(opacity-100)"
:class="{
'!hover:(text-brand-700 bg-brand-100) !group-hover:(text-brand-500)': compareCols(
displayColumn,
activeField,
),
'!hover:(text-gray-700 bg-gray-200) !group-hover:(text-gray-500)': !compareCols(
displayColumn,
activeField,
),
}"
>
<GeneralIcon icon="threeDotVertical" class="no-action text-inherit" />
</NcButton>
<template #overlay>
<NcMenu>
<NcTooltip placement="top">
<template #title>{{ $t('msg.clickToCopyFieldId') }}</template>
<div
class="flex flex-row px-3 py-2 w-46 justify-between items-center group hover:bg-gray-100 cursor-pointer"
@click="onClickCopyFieldUrl(displayColumn)"
>
<div class="flex flex-row items-baseline gap-x-1 font-bold text-xs">
<div class="text-gray-600">{{ $t('labels.idColon') }}</div>
<div class="flex flex-row text-gray-600 text-xs">
{{ displayColumn.id }}
</div>
</div>
<NcButton size="xsmall" type="secondary" class="!group-hover:bg-gray-100">
<GeneralIcon v-if="isFieldIdCopied" icon="check" />
<GeneralIcon v-else icon="copy" />
</NcButton>
</div>
</NcTooltip>
</NcMenu>
</template>
</NcDropdown>
<MdiChevronRight <MdiChevronRight
class="text-brand-500 opacity-0" class="text-brand-500 opacity-0"
:class="{ :class="{
@ -954,7 +1058,26 @@ onMounted(async () => {
</div> </div>
</template> </template>
<style lang="scss">
.nc-dropdown-table-explorer {
@apply !overflow-hidden;
}
.nc-dropdown-table-explorer > div > ul.ant-dropdown-menu.nc-menu {
@apply !pt-0;
}
.nc-dropdown-table-explorer-display-column {
@apply !overflow-hidden;
}
.nc-dropdown-table-explorer-display-column > div > ul.ant-dropdown-menu.nc-menu {
@apply !py-1.5;
}
</style>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(ul.ant-dropdown-menu.nc-menu) {
@apply !pt-0;
}
.add { .add {
background-color: #e6ffed !important; background-color: #e6ffed !important;
border-color: #b7eb8f; border-color: #b7eb8f;

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

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

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

@ -41,11 +41,13 @@ interface Props {
showNextPrevIcons?: boolean showNextPrevIcons?: boolean
firstRow?: boolean firstRow?: boolean
lastRow?: boolean lastRow?: boolean
closeAfterSave?: boolean
newRecordHeader?: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev']) const emits = defineEmits(['update:modelValue', 'cancel', 'next', 'prev', 'createdRecord'])
const { activeView } = storeToRefs(useViewsStore()) const { activeView } = storeToRefs(useViewsStore())
@ -90,6 +92,8 @@ const reloadTrigger = inject(ReloadRowDataHookInj, createEventHook())
const { addOrEditStackRow } = useKanbanViewStoreOrThrow() const { addOrEditStackRow } = useKanbanViewStoreOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
// override cell click hook to avoid unexpected behavior at form fields // override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, undefined) provide(CellClickHookInj, undefined)
@ -127,7 +131,6 @@ const {
primaryKey, primaryKey,
saveRowAndStay, saveRowAndStay,
row: _row, row: _row,
syncLTARRefs,
save: _save, save: _save,
loadCommentsAndLogs, loadCommentsAndLogs,
clearColumns, clearColumns,
@ -185,7 +188,6 @@ const onDuplicateRow = () => {
const save = async () => { const save = async () => {
if (isNew.value) { if (isNew.value) {
const data = await _save(rowState.value) const data = await _save(rowState.value)
await syncLTARRefs(data)
reloadTrigger?.trigger() reloadTrigger?.trigger()
} else { } else {
let kanbanClbk let kanbanClbk
@ -201,6 +203,12 @@ const save = async () => {
reloadTrigger?.trigger() reloadTrigger?.trigger()
} }
isUnsavedFormExist.value = false isUnsavedFormExist.value = false
if (props.closeAfterSave) {
isExpanded.value = false
}
emits('createdRecord', _row.value.row)
} }
const isPreventChangeModalOpen = ref(false) const isPreventChangeModalOpen = ref(false)
@ -283,6 +291,9 @@ const cellWrapperEl = ref()
onMounted(async () => { onMounted(async () => {
isRecordLinkCopied.value = false isRecordLinkCopied.value = false
isLoading.value = true isLoading.value = true
const focusFirstCell = !isExpandedFormCommentMode.value
if (props.loadRow) { if (props.loadRow) {
await _loadRow() await _loadRow()
await loadCommentsAndLogs() await loadCommentsAndLogs()
@ -302,9 +313,11 @@ onMounted(async () => {
isLoading.value = false isLoading.value = false
setTimeout(() => { if (focusFirstCell) {
cellWrapperEl.value?.$el?.querySelector('input,select,textarea')?.focus() setTimeout(() => {
}, 300) cellWrapperEl.value?.$el?.querySelector('input,select,textarea')?.focus()
}, 300)
}
}) })
const addNewRow = () => { const addNewRow = () => {
@ -340,8 +353,7 @@ useActiveKeyupListener(
e.stopPropagation() e.stopPropagation()
if (isNew.value) { if (isNew.value) {
const data = await _save(rowState.value) await _save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null) reloadHook?.trigger(null)
} else { } else {
await save() await save()
@ -375,8 +387,7 @@ useActiveKeyupListener(
okText: t('general.save'), okText: t('general.save'),
cancelText: t('labels.discard'), cancelText: t('labels.discard'),
onOk: async () => { onOk: async () => {
const data = await _save(rowState.value) await _save(rowState.value)
await syncLTARRefs(data)
reloadHook?.trigger(null) reloadHook?.trigger(null)
addNewRow() addNewRow()
}, },
@ -483,12 +494,17 @@ export default {
<div v-if="isLoading"> <div v-if="isLoading">
<a-skeleton-input class="!h-8 !sm:mr-14 !w-52 mt-1 !rounded-md !overflow-hidden" active size="small" /> <a-skeleton-input class="!h-8 !sm:mr-14 !w-52 mt-1 !rounded-md !overflow-hidden" active size="small" />
</div> </div>
<div
v-if="row.rowMeta?.new || props.newRecordHeader"
class="flex items-center truncate font-bold text-gray-800 text-xl"
>
{{ props.newRecordHeader ?? $t('activity.newRecord') }}
</div>
<div v-else-if="displayValue && !row.rowMeta?.new" class="flex items-center font-bold text-gray-800 text-xl w-64"> <div v-else-if="displayValue && !row.rowMeta?.new" class="flex items-center font-bold text-gray-800 text-xl w-64">
<span class="truncate"> <span class="truncate">
{{ displayValue }} {{ displayValue }}
</span> </span>
</div> </div>
<div v-if="row.rowMeta?.new" class="flex items-center truncate font-bold text-gray-800 text-xl">New Record</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<NcButton <NcButton

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

@ -144,6 +144,8 @@ const { addUndo, clone, defineViewScope } = useUndoRedo()
const { isViewColumnsLoading, updateGridViewColumn, gridViewCols, resizingColOldWith } = useViewColumnsOrThrow() const { isViewColumnsLoading, updateGridViewColumn, gridViewCols, resizingColOldWith } = useViewColumnsOrThrow()
const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const { const {
predictingNextColumn, predictingNextColumn,
predictedNextColumn, predictedNextColumn,
@ -179,7 +181,7 @@ const gridRect = useElementBounding(gridWrapper)
// #Permissions // #Permissions
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('dataEdit')) const hasEditPermission = computed(() => isUIAllowed('dataEdit') && !isLocked.value)
const isAddingColumnAllowed = computed(() => !readOnly.value && !isLocked.value && isUIAllowed('fieldAdd') && !isSqlView.value) const isAddingColumnAllowed = computed(() => !readOnly.value && !isLocked.value && isUIAllowed('fieldAdd') && !isSqlView.value)
const { onDrag, onDragStart, draggedCol, dragColPlaceholderDomRef, toBeDroppedColId } = useColumnDrag({ const { onDrag, onDragStart, draggedCol, dragColPlaceholderDomRef, toBeDroppedColId } = useColumnDrag({
@ -221,9 +223,7 @@ const _contextMenu = ref(false)
const contextMenu = computed({ const contextMenu = computed({
get: () => _contextMenu.value, get: () => _contextMenu.value,
set: (val) => { set: (val) => {
if (hasEditPermission.value) { _contextMenu.value = val
_contextMenu.value = val
}
}, },
}) })
const contextMenuClosing = ref(false) const contextMenuClosing = ref(false)
@ -385,7 +385,7 @@ const gridWrapperClass = computed<string>(() => {
const classes = [] const classes = []
if (headerOnly !== true) { if (headerOnly !== true) {
if (!scrollParent.value) { if (!scrollParent.value) {
classes.push('nc-scrollbar-x-lg overflow-auto') classes.push('nc-scrollbar-x-lg !overflow-auto')
} }
} else { } else {
classes.push('overflow-visible') classes.push('overflow-visible')
@ -710,6 +710,23 @@ const confirmDeleteRow = (row: number) => {
} }
} }
const commentRow = (rowId: number) => {
try {
isExpandedFormCommentMode.value = true
const row = dataRef.value[rowId]
if (expandForm) {
expandForm(row)
}
activeCell.row = null
activeCell.col = null
selectedRange.clear()
} catch (e: any) {
message.error(e.message)
}
}
const deleteSelectedRangeOfRows = () => { const deleteSelectedRangeOfRows = () => {
deleteRangeOfRows?.(selectedRange).then(() => { deleteRangeOfRows?.(selectedRange).then(() => {
clearSelectedRange() clearSelectedRange()
@ -1253,13 +1270,14 @@ onKeyStroke('ArrowDown', onDown)
:trigger="isSqlView ? [] : ['contextmenu']" :trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu" overlay-class-name="nc-dropdown-grid-context-menu"
> >
<div class="table-overlay" :class="{ 'nc-grid-skelton-loader': showSkeleton }"> <div class="table-overlay" :class="{ 'nc-grid-skeleton-loader': showSkeleton }">
<table <table
ref="smartTable" ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative pr-60 pb-12" class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative"
:class="{ :class="{
mobile: isMobileMode, 'mobile': isMobileMode,
desktop: !isMobileMode, 'desktop': !isMobileMode,
'pr-60 pb-12': !headerOnly,
}" }"
@contextmenu="showContextMenu" @contextmenu="showContextMenu"
> >
@ -1529,14 +1547,12 @@ onKeyStroke('ArrowDown', onDown)
<SmartsheetTableDataCell <SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields" v-for="(columnObj, colIndex) of fields"
:key="columnObj.id" :key="columnObj.id"
class="cell relative nc-grid-cell" class="cell relative nc-grid-cell cursor-pointer"
:class="{ :class="{
'cursor-pointer': hasEditPermission, 'active': isCellSelected(rowIndex, colIndex),
'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'active-cell': 'active-cell':
hasEditPermission && (activeCell.row === rowIndex && activeCell.col === colIndex) ||
((activeCell.row === rowIndex && activeCell.col === colIndex) || (selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex),
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex)),
'last-cell': 'last-cell':
rowIndex === (isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) && rowIndex === (isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) &&
colIndex === (isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col), colIndex === (isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col),
@ -1574,7 +1590,7 @@ onKeyStroke('ArrowDown', onDown)
:column="columnObj" :column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex" :active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row" :row="row"
:read-only="readOnly" :read-only="!hasEditPermission"
@navigate="onNavigate" @navigate="onNavigate"
@save="updateOrSaveRow?.(row, '', state)" @save="updateOrSaveRow?.(row, '', state)"
/> />
@ -1588,7 +1604,7 @@ onKeyStroke('ArrowDown', onDown)
" "
:row-index="rowIndex" :row-index="rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex" :active="activeCell.col === colIndex && activeCell.row === rowIndex"
:read-only="readOnly" :read-only="!hasEditPermission"
@update:edit-enabled="editEnabled = $event" @update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow?.(row, columnObj.title, state)" @save="updateOrSaveRow?.(row, columnObj.title, state)"
@navigate="onNavigate" @navigate="onNavigate"
@ -1638,7 +1654,7 @@ onKeyStroke('ArrowDown', onDown)
/> />
</div> </div>
<template v-if="!isLocked && hasEditPermission" #overlay> <template #overlay>
<NcMenu class="!rounded !py-0" @click="contextMenu = false"> <NcMenu class="!rounded !py-0" @click="contextMenu = false">
<NcMenuItem <NcMenuItem
v-if="isEeUI && !contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)" v-if="isEeUI && !contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)"
@ -1689,6 +1705,7 @@ onKeyStroke('ArrowDown', onDown)
<NcMenuItem <NcMenuItem
v-if=" v-if="
contextMenuTarget && contextMenuTarget &&
hasEditPermission &&
selectedRange.isSingleCell() && selectedRange.isSingleCell() &&
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col])) (isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col]))
" "
@ -1702,7 +1719,7 @@ onKeyStroke('ArrowDown', onDown)
<!-- Clear cell --> <!-- Clear cell -->
<NcMenuItem <NcMenuItem
v-else-if="contextMenuTarget" v-else-if="contextMenuTarget && hasEditPermission"
v-e="['a:row:clear-range']" v-e="['a:row:clear-range']"
class="nc-base-menu-item" class="nc-base-menu-item"
@click="clearSelectedRangeOfCells()" @click="clearSelectedRangeOfCells()"
@ -1711,28 +1728,40 @@ onKeyStroke('ArrowDown', onDown)
{{ $t('general.clear') }} {{ $t('general.clear') }}
</NcMenuItem> </NcMenuItem>
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" /> <template
<NcMenuItem v-if="contextMenuTarget && !isLocked && selectedRange.isSingleCell() && isUIAllowed('commentEdit') && !isMobileMode"
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="confirmDeleteRow(contextMenuTarget.row)"
> >
<GeneralIcon icon="delete" /> <NcDivider />
<!-- Delete Row --> <NcMenuItem v-e="['a:row:comment']" class="nc-base-menu-item" @click="commentRow(contextMenuTarget.row)">
{{ $t('activity.deleteRow') }} <MdiMessageOutline class="h-4 w-4" />
</NcMenuItem>
<div v-else-if="contextMenuTarget && deleteRangeOfRows"> {{ $t('general.comment') }}
</NcMenuItem>
</template>
<template v-if="hasEditPermission">
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />
<NcMenuItem <NcMenuItem
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"
v-e="['a:row:delete']" v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50" class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="deleteSelectedRangeOfRows" @click="confirmDeleteRow(contextMenuTarget.row)"
> >
<GeneralIcon icon="delete" class="text-gray-500 text-red-600" /> <GeneralIcon icon="delete" />
<!-- Delete Rows --> <!-- Delete Row -->
{{ $t('activity.deleteRows') }} {{ $t('activity.deleteRow') }}
</NcMenuItem> </NcMenuItem>
</div> <div v-else-if="contextMenuTarget && deleteRangeOfRows">
<NcMenuItem
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="deleteSelectedRangeOfRows"
>
<GeneralIcon icon="delete" class="text-gray-500 text-red-600" />
<!-- Delete Rows -->
{{ $t('activity.deleteRows') }}
</NcMenuItem>
</div>
</template>
</NcMenu> </NcMenu>
</template> </template>
</NcDropdown> </NcDropdown>
@ -1971,7 +2000,7 @@ onKeyStroke('ArrowDown', onDown)
} }
} }
.nc-grid-skelton-loader { .nc-grid-skeleton-loader {
thead th:nth-child(2) { thead th:nth-child(2) {
@apply border-r-1 !border-r-gray-50; @apply border-r-1 !border-r-gray-50;
} }

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnReqType } from 'nocodb-sdk' import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR } from 'nocodb-sdk' import { RelationTypes, UITypes, isLinksOrLTAR } from 'nocodb-sdk'
import { import {
ActiveViewInj, ActiveViewInj,
@ -8,8 +8,6 @@ import {
MetaInj, MetaInj,
ReloadViewDataHookInj, ReloadViewDataHookInj,
SmartsheetStoreEvents, SmartsheetStoreEvents,
extractSdkResponseErrorMsg,
getUniqueColumnName,
iconMap, iconMap,
inject, inject,
message, message,
@ -112,48 +110,24 @@ const sortByColumn = async (direction: 'asc' | 'desc') => {
}) })
} }
const duplicateColumn = async () => { const isDuplicateDlgOpen = ref(false)
const selectedColumnToDuplicate = ref<ColumnType>()
const selectedColumnExtra = ref<any>()
const duplicateVirtualColumn = async () => {
let columnCreatePayload = {} let columnCreatePayload = {}
// generate duplicate column name // generate duplicate column title
const duplicateColumnName = getUniqueColumnName(`${column!.value.title}_copy`, meta!.value!.columns!) const duplicateColumnTitle = getUniqueColumnName(`${column!.value.title} copy`, meta!.value!.columns!)
// construct column create payload columnCreatePayload = {
switch (column?.value.uidt) { ...column!.value!,
case UITypes.LinkToAnotherRecord: ...(column!.value.colOptions ?? {}),
case UITypes.Links: title: duplicateColumnTitle,
case UITypes.Lookup: column_name: duplicateColumnTitle.replace(/\s/g, '_'),
case UITypes.Rollup: id: undefined,
case UITypes.Formula: colOptions: undefined,
return message.info('Not available at the moment') order: undefined,
case UITypes.SingleSelect:
case UITypes.MultiSelect:
columnCreatePayload = {
...column!.value!,
title: duplicateColumnName,
column_name: duplicateColumnName,
id: undefined,
order: undefined,
colOptions: {
options:
column.value.colOptions?.options?.map((option: Record<string, any>) => ({
...option,
id: undefined,
})) ?? [],
},
}
break
default:
columnCreatePayload = {
...column!.value!,
...(column!.value.colOptions ?? {}),
title: duplicateColumnName,
column_name: duplicateColumnName,
id: undefined,
colOptions: undefined,
order: undefined,
}
break
} }
try { try {
@ -170,9 +144,10 @@ const duplicateColumn = async () => {
await $api.dbTableColumn.create(meta!.value!.id!, { await $api.dbTableColumn.create(meta!.value!.id!, {
...columnCreatePayload, ...columnCreatePayload,
pv: false, pv: false,
view_id: view.value!.id as string,
column_order: { column_order: {
order: newColumnOrder, order: newColumnOrder,
view_id: view.value?.id as string, view_id: view.value!.id as string,
}, },
} as ColumnReqType) } as ColumnReqType)
await getMeta(meta!.value!.id!, true) await getMeta(meta!.value!.id!, true)
@ -188,6 +163,35 @@ const duplicateColumn = async () => {
isOpen.value = false isOpen.value = false
} }
const openDuplicateDlg = async () => {
if (!column?.value) return
if (column.value.uidt && [UITypes.Formula, UITypes.Lookup, UITypes.Rollup].includes(column.value.uidt as UITypes)) {
duplicateVirtualColumn()
} else {
const gridViewColumnList = (await $api.dbViewColumn.list(view.value?.id as string)).list
const currentColumnIndex = gridViewColumnList.findIndex((f) => f.fk_column_id === column!.value.id)
let newColumnOrder
if (currentColumnIndex === gridViewColumnList.length - 1) {
newColumnOrder = gridViewColumnList[currentColumnIndex].order! + 1
} else {
newColumnOrder = (gridViewColumnList[currentColumnIndex].order! + gridViewColumnList[currentColumnIndex + 1].order!) / 2
}
selectedColumnExtra.value = {
pv: false,
view_id: view.value!.id as string,
column_order: {
order: newColumnOrder,
view_id: view.value!.id as string,
},
}
selectedColumnToDuplicate.value = column.value
isDuplicateDlgOpen.value = true
isOpen.value = false
}
}
// add column before or after current column // add column before or after current column
const addColumn = async (before = false) => { const addColumn = async (before = false) => {
const gridViewColumnList = (await $api.dbViewColumn.list(view.value?.id as string)).list const gridViewColumnList = (await $api.dbViewColumn.list(view.value?.id as string)).list
@ -334,10 +338,7 @@ const onInsertAfter = () => {
<a-divider class="!my-0" /> <a-divider class="!my-0" />
<a-menu-item <a-menu-item v-if="!column?.pk" @click="openDuplicateDlg">
v-if="column.uidt !== UITypes.LinkToAnotherRecord && column.uidt !== UITypes.Lookup && !column.pk"
@click="duplicateColumn"
>
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item my-0.5"> <div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item my-0.5">
<component :is="iconMap.duplicate" class="text-gray-700 mx-0.75" /> <component :is="iconMap.duplicate" class="text-gray-700 mx-0.75" />
<!-- Duplicate --> <!-- Duplicate -->
@ -371,6 +372,12 @@ const onInsertAfter = () => {
</template> </template>
</a-dropdown> </a-dropdown>
<SmartsheetHeaderDeleteColumnModal v-model:visible="showDeleteColumnModal" /> <SmartsheetHeaderDeleteColumnModal v-model:visible="showDeleteColumnModal" />
<DlgColumnDuplicate
v-if="selectedColumnToDuplicate"
v-model="isDuplicateDlgOpen"
:column="selectedColumnToDuplicate"
:extra="selectedColumnExtra"
/>
</template> </template>
<style scoped> <style scoped>

46
packages/nc-gui/components/smartsheet/toolbar/ExportSubActions.vue

@ -13,13 +13,9 @@ import {
ref, ref,
storeToRefs, storeToRefs,
useBase, useBase,
useI18n,
useNuxtApp, useNuxtApp,
useSmartsheetStoreOrThrow,
} from '#imports' } from '#imports'
const { t } = useI18n()
const isPublicView = inject(IsPublicInj, ref(false)) const isPublicView = inject(IsPublicInj, ref(false))
const fields = inject(FieldsInj, ref([])) const fields = inject(FieldsInj, ref([]))
@ -33,13 +29,17 @@ const meta = inject(MetaInj, ref())
const selectedView = inject(ActiveViewInj) const selectedView = inject(ActiveViewInj)
const { sorts, nestedFilters } = useSmartsheetStoreOrThrow() const { activeNestedFilters: nestedFilters, activeSorts: sorts } = storeToRefs(useViewsStore())
const isExportingType = ref<ExportTypes | undefined>(undefined)
const exportFile = async (exportType: ExportTypes) => { const exportFile = async (exportType: ExportTypes) => {
let offset = 0 let offset = 0
let c = 1 let c = 1
const responseType = exportType === ExportTypes.EXCEL ? 'base64' : 'blob' const responseType = exportType === ExportTypes.EXCEL ? 'base64' : 'blob'
isExportingType.value = exportType
const XLSX = await import('xlsx') const XLSX = await import('xlsx')
const FileSaver = await import('file-saver') const FileSaver = await import('file-saver')
@ -84,13 +84,10 @@ const exportFile = async (exportType: ExportTypes) => {
} }
offset = +headers['nc-export-offset'] offset = +headers['nc-export-offset']
if (offset > -1) {
// Downloading more files setTimeout(() => {
message.info(t('msg.info.downloadingMoreFiles')) isExportingType.value = undefined
} else { }, 200)
// Successfully exported all table data
message.success(t('msg.success.tableDataExported'))
}
} }
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
@ -99,17 +96,30 @@ const exportFile = async (exportType: ExportTypes) => {
</script> </script>
<template> <template>
<a-menu-item> <div class="flex py-3 px-4 font-bold uppercase text-xs text-gray-500">{{ $t('labels.downloadData') }}</div>
<div v-e="['a:download:csv']" class="nc-base-menu-item" @click="exportFile(ExportTypes.CSV)">
<component :is="iconMap.csv" /> <a-menu-item class="!mx-1 !py-2 !rounded-md">
<div
v-e="['a:download:csv']"
class="flex flex-row items-center nc-base-menu-item !py-0"
@click.stop="exportFile(ExportTypes.CSV)"
>
<GeneralLoader v-if="isExportingType === ExportTypes.CSV" class="!max-h-4.5 !-mt-1 !mr-0.7" />
<component :is="iconMap.csv" v-else />
<!-- Download as CSV --> <!-- Download as CSV -->
{{ $t('activity.downloadCSV') }} {{ $t('activity.downloadCSV') }}
</div> </div>
</a-menu-item> </a-menu-item>
<a-menu-item> <a-menu-item class="!mx-1 !py-2 !rounded-md">
<div v-e="['a:download:excel']" class="nc-base-menu-item" @click="exportFile(ExportTypes.EXCEL)"> <div
<component :is="iconMap.excel" /> v-e="['a:download:excel']"
class="flex flex-row items-center nc-base-menu-item !py-0"
@click="exportFile(ExportTypes.EXCEL)"
>
<GeneralLoader v-if="isExportingType === ExportTypes.EXCEL" class="!max-h-4.5 !-mt-1 !mr-0.7" />
<component :is="iconMap.excel" v-else />
<!-- Download as XLSX --> <!-- Download as XLSX -->
{{ $t('activity.downloadExcel') }} {{ $t('activity.downloadExcel') }}
</div> </div>

34
packages/nc-gui/components/smartsheet/toolbar/LockType.vue

@ -9,17 +9,17 @@ const emit = defineEmits(['select'])
const types = { const types = {
[LockType.Personal]: { [LockType.Personal]: {
title: 'title.personalView', title: 'title.personal',
icon: iconMap.account, icon: iconMap.account,
subtitle: 'msg.info.personalView', subtitle: 'msg.info.personalView',
}, },
[LockType.Collaborative]: { [LockType.Collaborative]: {
title: 'title.collabView', title: 'title.collaborative',
icon: UsersIcon, icon: UsersIcon,
subtitle: 'msg.info.collabView', subtitle: 'msg.info.collabView',
}, },
[LockType.Locked]: { [LockType.Locked]: {
title: 'title.lockedView', title: 'title.locked',
icon: LockIcon, icon: LockIcon,
subtitle: 'msg.info.lockedView', subtitle: 'msg.info.lockedView',
}, },
@ -29,29 +29,31 @@ const selectedView = inject(ActiveViewInj)
</script> </script>
<template> <template>
<div class="nc-locked-menu-item min-w-50" @click="emit('select', type)"> <div class="nc-locked-menu-item !px-1 text-gray-800" @click="emit('select', type)">
<div :class="{ 'show-tick': !hideTick }"> <div :class="{ 'show-tick': !hideTick }">
<div class="flex items-center gap-2 flex-grow"> <div class="flex flex-col gap-y-1">
<component :is="types[type].icon" class="text-gray-800 !w-4 !h-4" /> <div class="flex items-center gap-2 flex-grow">
<div class="flex flex-col"> <component :is="types[type].icon" class="!w-4 !min-w-4 text-inherit !h-4" />
{{ $t(types[type].title) }} <div class="flex">
<div v-if="!hideTick" class="nc-subtitle max-w-120 text-sm text-gray-500 whitespace-normal"> {{ $t(types[type].title) }}
{{ $t(types[type].subtitle) }}
</div> </div>
<div v-if="!hideTick" class="flex flex-grow"></div>
<template v-if="!hideTick">
<GeneralIcon v-if="selectedView?.lock_type === type" icon="check" class="!text-brand-500" />
<span v-else />
</template>
</div>
<div v-if="!hideTick" class="nc-subtitle max-w-120 text-sm text-gray-500 whitespace-normal ml-6">
{{ $t(types[type].subtitle) }}
</div> </div>
</div> </div>
<template v-if="!hideTick">
<GeneralIcon v-if="selectedView?.lock_type === type" icon="check" />
<span v-else />
</template>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.nc-locked-menu-item > div { .nc-locked-menu-item > div {
@apply py-2 items-center; @apply !py-0 items-center;
&.show-tick { &.show-tick {
@apply flex gap-2; @apply flex gap-2;

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

@ -0,0 +1,207 @@
<script lang="ts" setup>
const { activeTable } = storeToRefs(useTablesStore())
const { isMobileMode } = useGlobal()
const { isSharedBase, base } = storeToRefs(useBase())
const { t } = useI18n()
const { $api } = useNuxtApp()
const { refreshCommandPalette } = useCommandPalette()
const { activeView, views, openedViewsTab, viewsByTable } = storeToRefs(useViewsStore())
const { loadViews, removeFromRecentViews } = useViewsStore()
const { navigateToTable } = useTablesStore()
const isDropdownOpen = ref(false)
const isViewIdCopied = ref(false)
const isRenaming = ref(false)
const renameInputDom = ref()
const viewRenameTitle = ref('')
const error = ref<string | undefined>()
const onRenameMenuClick = () => {
isRenaming.value = true
isDropdownOpen.value = false
viewRenameTitle.value = activeView.value!.title
setTimeout(() => {
renameInputDom.value.focus()
})
}
watch(renameInputDom, () => {
renameInputDom.value?.focus()
})
const onRenameBlur = async () => {
if (validate()) {
activeView.value!.title = viewRenameTitle.value
isRenaming.value = false
error.value = undefined
await $api.dbView.update(activeView.value!.id!, {
title: viewRenameTitle.value,
})
} else {
renameInputDom.value?.focus()
}
}
/** validate view title */
function validate() {
if (!viewRenameTitle.value || viewRenameTitle.value.trim().length < 0) {
error.value = t('msg.error.viewNameRequired')
return false
}
if (viewRenameTitle.value.trim().length > 255) {
error.value = t('msg.error.nameMaxLength256')
return false
}
if (views.value.some((v) => v.title === viewRenameTitle.value && v.id !== activeView.value!.id)) {
error.value = t('msg.error.viewNameDuplicate')
return false
}
return true
}
watch(viewRenameTitle, () => {
if (error.value) {
error.value = undefined
}
})
watch(isDropdownOpen, () => {
setTimeout(() => {
isViewIdCopied.value = false
}, 250)
})
const resetViewRename = () => {
viewRenameTitle.value = activeView.value!.title
isRenaming.value = false
}
function openDeleteDialog() {
const isOpen = ref(true)
isDropdownOpen.value = false
const { close } = useDialog(resolveComponent('DlgViewDelete'), {
'modelValue': isOpen,
'view': activeView.value,
'onUpdate:modelValue': closeDialog,
'onDeleted': async () => {
closeDialog()
removeFromRecentViews({ viewId: activeView.value!.id, tableId: activeView.value!.fk_model_id, baseId: base.value.id })
refreshCommandPalette()
if (activeView.value?.id === activeView.value!.id) {
navigateToTable({
tableId: activeTable.value!.id!,
baseId: base.value.id!,
})
}
await loadViews({
tableId: activeTable.value!.id!,
force: true,
})
const activeNonDefaultViews = viewsByTable.value.get(activeTable!.value!.id!)?.filter((v) => !v.is_default) ?? []
activeTable!.value!.meta = {
...(activeTable!.value!.meta as object),
hasNonDefaultViews: activeNonDefaultViews.length > 1,
}
},
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script>
<template>
<div
v-if="isRenaming"
class="h-6 relative"
:class="{
'max-w-2/5': !isSharedBase && !isMobileMode && activeView?.is_default,
'max-w-3/5': !isSharedBase && !isMobileMode && !activeView?.is_default,
}"
>
<input
ref="renameInputDom"
v-model="viewRenameTitle"
class="ml-0.25 w-full px-1 py-0.5 rounded-md font-medium text-gray-800"
:class="{
'outline-brand-500': !error,
'outline-red-500 pr-6': error,
}"
@blur="onRenameBlur"
@keydown.enter="onRenameBlur"
@keydown.esc="resetViewRename"
/>
<NcTooltip v-if="error" class="absolute top-0.25 right-0.5 bg-white rounded-lg">
<template #title>
{{ error }}
</template>
<GeneralIcon icon="info" class="cursor-pointer" />
</NcTooltip>
</div>
<NcDropdown
v-else
v-model:visible="isDropdownOpen"
v-e="['c:breadcrumb:view-actions']"
class="!xs:pointer-events-none nc-actions-menu-btn nc-view-context-btn"
overlay-class-name="nc-dropdown-actions-menu"
>
<div
class="truncate nc-active-view-title !hover:(bg-gray-100 text-gray-800) ml-0.25 pl-1 pr-0.25 rounded-md py-1 cursor-pointer"
:class="{
'max-w-2/5': !isSharedBase && !isMobileMode && activeView?.is_default,
'max-w-3/5': !isSharedBase && !isMobileMode && !activeView?.is_default,
'max-w-1/2': isMobileMode,
'text-gray-500': activeView?.is_default,
'text-gray-800 font-medium': !activeView?.is_default,
}"
>
<span
class="truncate xs:pl-1.25 text-inherit"
:class="{
'max-w-28/100': !isMobileMode,
}"
>
{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</span>
<GeneralIcon icon="arrowDown" class="ml-1" />
</div>
<template #overlay>
<SmartsheetToolbarViewActionMenu
:table="activeTable"
:view="activeView"
@close-modal="isDropdownOpen = false"
@rename="onRenameMenuClick"
@delete="openDeleteDialog"
/>
</template>
</NcDropdown>
<LazySmartsheetToolbarReload v-if="openedViewsTab === 'view' && !isMobileMode && !isRenaming" />
</template>

8
packages/nc-gui/components/smartsheet/toolbar/ShareView.vue

@ -271,7 +271,13 @@ const copyIframeCode = async () => {
<div class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100"> <div class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100">
<div data-testid="nc-modal-share-view__link" class="flex-1 h-min text-xs text-gray-500">{{ sharedViewUrl }}</div> <div data-testid="nc-modal-share-view__link" class="flex-1 h-min text-xs text-gray-500">{{ sharedViewUrl }}</div>
<a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank" class="flex items-center !no-underline"> <a
v-e="['c:view:share:open-url']"
:href="sharedViewUrl"
target="_blank"
rel="noopener noreferrer"
class="flex items-center !no-underline"
>
<component :is="iconMap.share" class="text-sm text-gray-500" /> <component :is="iconMap.share" class="text-sm text-gray-500" />
</a> </a>

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

@ -113,7 +113,7 @@ onMounted(() => {
<div :class="{ 'nc-active-btn': sorts?.length }"> <div :class="{ 'nc-active-btn': sorts?.length }">
<a-button v-e="['c:sort']" class="nc-sort-menu-btn nc-toolbar-btn" :disabled="isLocked"> <a-button v-e="['c:sort']" class="nc-sort-menu-btn nc-toolbar-btn" :disabled="isLocked">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<component :is="iconMap.sort" class="h-4 w-4" /> <component :is="iconMap.sort" class="h-4 w-4 text-inherit" />
<!-- Sort --> <!-- Sort -->
<span v-if="!isMobileMode" class="text-capitalize !text-sm font-medium">{{ $t('activity.sort') }}</span> <span v-if="!isMobileMode" class="text-capitalize !text-sm font-medium">{{ $t('activity.sort') }}</span>

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

@ -0,0 +1,317 @@
<script lang="ts" setup>
import type { TableType, ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
import { LockType } from '~/lib'
import UploadIcon from '~icons/nc-icons/upload'
import DownloadIcon from '~icons/nc-icons/download'
const props = defineProps<{
view: ViewType
table: TableType
inSidebar: boolean
}>()
const emits = defineEmits(['rename', 'closeModal', 'delete'])
const { isUIAllowed } = useRoles()
const isPublicView = inject(IsPublicInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false))
const { $api, $e } = useNuxtApp()
const { t } = useI18n()
const view = computed(() => props.view)
const table = computed(() => props.table)
const { viewsByTable } = storeToRefs(useViewsStore())
const { loadViews, navigateToView } = useViewsStore()
const { base } = storeToRefs(useBase())
const { refreshCommandPalette } = useCommandPalette()
const lockType = computed(() => (view.value?.lock_type as LockType) || LockType.Collaborative)
const views = computed(() => viewsByTable.value.get(table.value.id!))
const isViewIdCopied = ref(false)
const currentBaseId = computed(() => table.value?.source_id)
const onRenameMenuClick = () => {
emits('rename')
}
const quickImportDialogTypes: QuickImportDialogType[] = ['csv', 'excel']
const quickImportDialogs: Record<(typeof quickImportDialogTypes)[number], Ref<boolean>> = quickImportDialogTypes.reduce(
(acc: any, curr) => {
acc[curr] = ref(false)
return acc
},
{},
) as Record<QuickImportDialogType, Ref<boolean>>
const onImportClick = (dialog: any) => {
if (isLocked.value) return
emits('closeModal')
dialog.value = true
}
async function changeLockType(type: LockType) {
$e('a:grid:lockmenu', { lockType: type, sidebar: props.inSidebar })
if (!view.value) return
if (type === 'personal') {
// Coming soon
return message.info(t('msg.toast.futureRelease'))
}
try {
view.value.lock_type = type
await $api.dbView.update(view.value.id as string, {
lock_type: type,
})
message.success(`Successfully Switched to ${type} view`)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
emits('closeModal')
}
/** Duplicate a view */
// todo: This is not really a duplication, maybe we need to implement a true duplication?
function onDuplicate() {
emits('closeModal')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgViewCreate'), {
'modelValue': isOpen,
'title': view.value!.title,
'type': view.value!.type as ViewTypes,
'tableId': table.value!.id,
'selectedViewId': view.value!.id,
'groupingFieldColumnId': view.value!.view!.fk_grp_col_id,
'views': views,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
refreshCommandPalette()
await loadViews({
force: true,
tableId: table.value!.id!,
})
navigateToView({
view,
tableId: table.value!.id!,
baseId: base.value.id!,
hardReload: view.type === ViewTypes.FORM,
})
$e('a:view:create', { view: view.type, sidebar: props.inSidebar })
},
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
const { copy } = useCopy()
const onViewIdCopy = async () => {
await copy(view.value!.id!)
isViewIdCopied.value = true
}
const onDelete = async () => {
emits('delete')
}
</script>
<template>
<NcMenu class="!min-w-70" data-id="toolbar-actions" :data-testid="`view-sidebar-view-actions-${view!.alias || view!.title}`">
<NcTooltip>
<template #title> {{ $t('labels.clickToCopyViewID') }} </template>
<div class="flex items-center justify-between py-2 px-3 cursor-pointer hover:bg-gray-100 group" @click="onViewIdCopy">
<div class="flex text-xs font-bold text-gray-500 ml-1">
{{
$t('labels.viewIdColon', {
viewId: view?.id,
})
}}
</div>
<NcButton size="xsmall" type="secondary" class="!group-hover:bg-gray-100">
<GeneralIcon v-if="isViewIdCopied" icon="check" class="max-h-4 min-w-4" />
<GeneralIcon v-else else icon="copy" class="max-h-4 min-w-4" />
</NcButton>
</div>
</NcTooltip>
<NcDivider />
<template v-if="!view?.is_default">
<NcMenuItem @click="onRenameMenuClick">
<GeneralIcon icon="edit" />
{{ $t('activity.renameView') }}
</NcMenuItem>
<NcMenuItem @click="onDuplicate">
<GeneralIcon icon="duplicate" class="nc-view-copy-icon" />
{{ $t('labels.duplicateView') }}
</NcMenuItem>
<NcDivider />
</template>
<template v-if="view.type !== ViewTypes.FORM">
<template v-if="isUIAllowed('csvTableImport') && !isPublicView">
<NcSubMenu key="upload">
<template #title>
<div
v-e="[
'c:navdraw:preview-as',
{
sidebar: props.inSidebar,
},
]"
class="nc-base-menu-item group"
>
<UploadIcon class="w-4 h-4" />
{{ $t('general.upload') }}
</div>
</template>
<template #expandIcon></template>
<div class="flex py-3 px-4 font-bold uppercase text-xs text-gray-500">{{ $t('activity.uploadData') }}</div>
<template v-for="(dialog, type) in quickImportDialogs">
<NcMenuItem v-if="isUIAllowed(`${type}TableImport`) && !isPublicView" :key="type" @click="onImportClick(dialog)">
<div
v-e="[
`a:upload:${type}`,
{
sidebar: props.inSidebar,
},
]"
class="nc-base-menu-item"
:class="{ disabled: isLocked }"
>
<component :is="iconMap.upload" />
{{ `${$t('general.upload')} ${type.toUpperCase()}` }}
</div>
</NcMenuItem>
</template>
</NcSubMenu>
</template>
<NcSubMenu key="download">
<template #title>
<div
v-e="[
'c:download',
{
sidebar: props.inSidebar,
},
]"
class="nc-base-menu-item group nc-view-context-download-option"
>
<DownloadIcon class="w-4 h-4" />
{{ $t('general.download') }}
</div>
</template>
<template #expandIcon></template>
<LazySmartsheetToolbarExportSubActions />
</NcSubMenu>
<NcDivider />
</template>
<NcSubMenu v-if="isUIAllowed('viewCreateOrEdit')" key="lock-type" class="scrollbar-thin-dull max-h-90vh overflow-auto !py-0">
<template #title>
<div
v-e="[
'c:navdraw:preview-as',
{
sidebar: props.inSidebar,
},
]"
class="flex flex-row items-center gap-x-3"
>
<div>
{{ $t('labels.viewMode') }}
</div>
<div class="nc-base-menu-item flex !flex-shrink group !py-1 !px-1 rounded-md bg-brand-50">
<LazySmartsheetToolbarLockType
hide-tick
:type="lockType"
class="flex nc-view-actions-lock-type !text-brand-500 !flex-shrink"
/>
</div>
<div class="flex flex-grow"></div>
</div>
</template>
<template #expandIcon></template>
<div class="flex py-3 px-4 font-bold uppercase text-xs text-gray-500">{{ $t('labels.viewMode') }}</div>
<a-menu-item class="!mx-1 !py-2 !rounded-md nc-view-action-lock-subaction">
<LazySmartsheetToolbarLockType :type="LockType.Collaborative" @click="changeLockType(LockType.Collaborative)" />
</a-menu-item>
<a-menu-item class="!mx-1 !py-2 !rounded-md nc-view-action-lock-subaction">
<LazySmartsheetToolbarLockType :type="LockType.Locked" @click="changeLockType(LockType.Locked)" />
</a-menu-item>
</NcSubMenu>
<template v-if="!view.is_default">
<NcDivider />
<NcMenuItem class="!hover:bg-red-50 !text-red-500" @click="onDelete">
<GeneralIcon icon="delete" class="nc-view-delete-icon" />
{{
$t('general.deleteEntity', {
entity: $t('objects.view'),
})
}}
</NcMenuItem>
</template>
<template v-if="currentBaseId">
<LazyDlgQuickImport
v-for="tp in quickImportDialogTypes"
:key="tp"
v-model="quickImportDialogs[tp].value"
:import-type="tp"
:source-id="currentBaseId"
:import-data-only="true"
/>
</template>
</NcMenu>
</template>
<style lang="scss" scoped>
.nc-base-menu-item {
@apply !py-0;
}
.nc-view-actions-lock-type {
@apply !min-w-0;
}
</style>
<style lang="scss">
.nc-view-actions-lock-type > div {
@apply !py-0;
}
.nc-view-action-lock-subaction {
@apply !min-w-82;
}
</style>

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

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const { isMobileMode } = useGlobal() const { isMobileMode } = useGlobal()
const { openedViewsTab, activeView } = storeToRefs(useViewsStore()) const { activeView } = storeToRefs(useViewsStore())
const { base, isSharedBase } = storeToRefs(useBase()) const { base, isSharedBase } = storeToRefs(useBase())
const { baseUrl } = useBase() const { baseUrl } = useBase()
@ -43,7 +43,9 @@ const openedBaseUrl = computed(() => {
> >
<NcTooltip class="!text-inherit"> <NcTooltip class="!text-inherit">
<template #title> <template #title>
{{ base?.title }} <span class="capitalize">
{{ base?.title }}
</span>
</template> </template>
<div class="flex flex-row items-center gap-x-1.5"> <div class="flex flex-row items-center gap-x-1.5">
<GeneralProjectIcon <GeneralProjectIcon
@ -59,14 +61,14 @@ const openedBaseUrl = computed(() => {
'!flex': isSharedBase && !isMobileMode, '!flex': isSharedBase && !isMobileMode,
}" }"
> >
<span class="truncate !text-inherit"> <span class="truncate !text-inherit capitalize">
{{ base?.title }} {{ base?.title }}
</span> </span>
</div> </div>
</div> </div>
</NcTooltip> </NcTooltip>
</NuxtLink> </NuxtLink>
<div class="px-1.5 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">
@ -119,7 +121,7 @@ const openedBaseUrl = computed(() => {
</div> </div>
</template> </template>
<div v-if="!isMobileMode" class="px-1 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">
@ -128,30 +130,7 @@ const openedBaseUrl = computed(() => {
</template> </template>
</LazyGeneralEmojiPicker> </LazyGeneralEmojiPicker>
<NcTooltip <SmartsheetToolbarOpenedViewAction />
class="truncate nc-active-view-title"
:class="{
'max-w-2/5': !isSharedBase && !isMobileMode && activeView?.is_default,
'max-w-3/5': !isSharedBase && !isMobileMode && !activeView?.is_default,
'max-w-1/2': isMobileMode,
}"
>
<template #title>
{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</template>
<span
class="truncate xs:pl-1.25"
:class="{
'max-w-28/100': !isMobileMode,
'text-gray-500': activeView?.is_default,
'text-gray-800 font-medium': !activeView?.is_default,
}"
>
{{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</span>
</NcTooltip>
</template> </template>
<LazySmartsheetToolbarReload v-if="openedViewsTab === 'view' && !isMobileMode" />
</div> </div>
</template> </template>

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

@ -47,7 +47,7 @@ const relatedTableDisplayColumn = computed(
loadRelatedTableMeta() loadRelatedTableMeta()
const textVal = computed(() => { const textVal = computed(() => {
if (isForm?.value) { if (isForm?.value || isNew.value) {
return state.value?.[colTitle.value]?.length return state.value?.[colTitle.value]?.length
? `${+state.value?.[colTitle.value]?.length} ${t('msg.recordsLinked')}` ? `${+state.value?.[colTitle.value]?.length} ${t('msg.recordsLinked')}`
: t('msg.noRecordsLinked') : t('msg.noRecordsLinked')

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

@ -59,8 +59,20 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = us
@ok="handleModalOkClick" @ok="handleModalOkClick"
> >
<template #footer> <template #footer>
<div class="mr-4 overflow-scroll p-2" data-testid="nc-qr-code-large-value-label"> <div class="flex flex-row">
{{ qrValue }} <div class="flex flex-row flex-grow mr-2 !overflow-y-auto py-2" data-testid="nc-qr-code-large-value-label">
{{ qrValue }}
</div>
<a v-if="showQrCode" :href="qrCodeLarge" :download="`${qrValue}.png`">
<NcTooltip>
<template #title>
{{ $t('labels.clickToDownload') }}
</template>
<NcButton size="small" type="secondary">
<GeneralIcon icon="download" class="w-4 h-4" />
</NcButton>
</NcTooltip>
</a>
</div> </div>
</template> </template>
<img v-if="showQrCode" :src="qrCodeLarge" :alt="$t('title.qrCode')" /> <img v-if="showQrCode" :src="qrCodeLarge" :alt="$t('title.qrCode')" />

8
packages/nc-gui/components/virtual-cell/barcode/Barcode.vue

@ -46,9 +46,15 @@ const rowHeight = inject(RowHeightInj, ref(undefined))
:footer="null" :footer="null"
@ok="handleModalOkClick" @ok="handleModalOkClick"
> >
<JsBarcodeWrapper v-if="showBarcode" :barcode-value="barcodeValue" :barcode-format="barcodeMeta.barcodeFormat" /> <JsBarcodeWrapper
v-if="showBarcode"
:barcode-value="barcodeValue"
:barcode-format="barcodeMeta.barcodeFormat"
show-download
/>
</a-modal> </a-modal>
<div <div
v-if="!tooManyCharsForBarcode"
class="flex ml-2 w-full items-center" class="flex ml-2 w-full items-center"
:class="{ :class="{
'justify-start': isExpandedFormOpen, 'justify-start': isExpandedFormOpen,

42
packages/nc-gui/components/virtual-cell/barcode/JsBarcodeWrapper.vue

@ -1,18 +1,20 @@
<script lang="ts" setup> <script lang="ts" setup>
import JsBarcode from 'jsbarcode' import JsBarcode from 'jsbarcode'
import { IsGalleryInj, onMounted } from '#imports' import { IsGalleryInj, onMounted } from '#imports'
import { downloadSvg as _downloadSvg } from '~/utils/svgToPng'
const props = defineProps({ const props = defineProps({
barcodeValue: { type: String, required: true }, barcodeValue: { type: String, required: true },
barcodeFormat: { type: String, required: true }, barcodeFormat: { type: String, required: true },
customStyle: { type: Object, required: false }, customStyle: { type: Object, required: false },
showDownload: { type: Boolean, required: false, default: false },
}) })
const emit = defineEmits(['onClickBarcode']) const emit = defineEmits(['onClickBarcode'])
const isGallery = inject(IsGalleryInj, ref(false)) const isGallery = inject(IsGalleryInj, ref(false))
const barcodeSvgRef = ref<HTMLElement>() const barcodeSvgRef = ref<SVGGraphicsElement>()
const errorForCurrentInput = ref(false) const errorForCurrentInput = ref(false)
const generate = () => { const generate = () => {
@ -34,6 +36,12 @@ const generate = () => {
} }
} }
const downloadSvg = () => {
if (!barcodeSvgRef.value) return
_downloadSvg(barcodeSvgRef.value, `${props.barcodeValue}.png`)
}
const onBarcodeClick = (ev: MouseEvent) => { const onBarcodeClick = (ev: MouseEvent) => {
if (isGallery.value) return if (isGallery.value) return
ev.stopPropagation() ev.stopPropagation()
@ -45,15 +53,25 @@ onMounted(generate)
</script> </script>
<template> <template>
<svg <div class="relative">
v-show="!errorForCurrentInput" <svg
ref="barcodeSvgRef" v-show="!errorForCurrentInput"
:class="{ ref="barcodeSvgRef"
'w-full': !isGallery, :class="{
'w-auto': isGallery, 'w-full': !isGallery,
}" 'w-auto': isGallery,
data-testid="barcode" }"
@click="onBarcodeClick" data-testid="barcode"
></svg> @click="onBarcodeClick"
<slot v-if="errorForCurrentInput" name="barcodeRenderError" /> ></svg>
<slot v-if="errorForCurrentInput" name="barcodeRenderError" />
<NcTooltip class="!absolute bottom-0 right-0">
<template #title>
{{ $t('labels.clickToDownload') }}
</template>
<NcButton v-if="props.showDownload" size="small" type="secondary" @click="downloadSvg">
<GeneralIcon icon="download" class="w-4 h-4" />
</NcButton>
</NcTooltip>
</div>
</template> </template>

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

@ -5,9 +5,9 @@ import FileIcon from '~icons/nc-icons/file'
import { iconMap } from '#imports' import { iconMap } from '#imports'
const { relation, relatedTableTitle, displayValue, showHeader, tableTitle } = defineProps<{ const { relation, relatedTableTitle, displayValue, header, tableTitle } = defineProps<{
relation: string relation: string
showHeader?: boolean header?: string | null
tableTitle: string tableTitle: string
relatedTableTitle: string relatedTableTitle: string
displayValue?: string displayValue?: string
@ -54,12 +54,12 @@ const relationMeta = computed(() => {
<template> <template>
<div class="flex sm:justify-between relative pb-2 items-center"> <div class="flex sm:justify-between relative pb-2 items-center">
<div v-if="!isMobileMode" class="flex text-base font-bold justify-start items-center min-w-36"> <div v-if="!isMobileMode" class="flex text-base font-bold justify-start items-center min-w-36">
{{ showHeader ? 'Linked Records' : '' }} {{ header ?? '' }}
</div> </div>
<div class="flex flex-row sm:w-[calc(100%-16rem)] xs:w-full items-center justify-center gap-2 xs:(h-full)"> <div class="flex flex-row sm:w-[calc(100%-16rem)] xs:w-full items-center justify-center gap-2 xs:(h-full)">
<div class="flex sm:justify-end w-[calc(50%-1.5rem)] xs:(w-[calc(50%-1.5rem)] h-full)"> <div class="flex sm:justify-end w-[calc(50%-1.5rem)] xs:(w-[calc(50%-1.5rem)] h-full)">
<div <div
class="flex max-w-full xs:w-full flex-shrink-0 xs:(h-full) rounded-md gap-1 text-brand-500 items-center bg-gray-100 px-2 py-1" class="flex max-w-full xs:w-full flex-shrink-0 xs:(h-full) rounded-md gap-1 text-gray-700 items-center bg-gray-100 px-2 py-1"
> >
<FileIcon class="w-4 h-4 min-w-4" /> <FileIcon class="w-4 h-4 min-w-4" />
<span class="truncate"> <span class="truncate">

8
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -134,10 +134,10 @@ onKeyStroke('Escape', () => {
}) })
/* /*
to render same number of skelton as the number of cards to render same number of skeleton as the number of cards
displayed displayed
*/ */
const skeltonCount = computed(() => { const skeletonCount = computed(() => {
if (props.items < 10 && childrenListPagination.page === 1) { if (props.items < 10 && childrenListPagination.page === 1) {
return props.items return props.items
} }
@ -192,7 +192,7 @@ const linkOrUnLink = (rowRef: Record<string, string>, id: string) => {
:relation="relation" :relation="relation"
:linked-records="childrenListCount" :linked-records="childrenListCount"
:table-title="meta?.title" :table-title="meta?.title"
:show-header="true" :header="$t('activity.linkedRecords')"
:related-table-title="relatedTableMeta?.title" :related-table-title="relatedTableMeta?.title"
:display-value="row.row[displayValueProp]" :display-value="row.row[displayValueProp]"
/> />
@ -222,7 +222,7 @@ const linkOrUnLink = (rowRef: Record<string, string>, id: string) => {
<div class="cursor-pointer pr-1"> <div class="cursor-pointer pr-1">
<template v-if="isChildrenLoading"> <template v-if="isChildrenLoading">
<div <div
v-for="(x, i) in Array.from({ length: skeltonCount })" v-for="(x, i) in Array.from({ length: skeletonCount })"
:key="i" :key="i"
class="!border-2 flex flex-row gap-2 mb-2 transition-all !rounded-xl relative !border-gray-200 hover:bg-gray-50" class="!border-2 flex flex-row gap-2 mb-2 transition-all !rounded-xl relative !border-gray-200 hover:bg-gray-50"
> >

67
packages/nc-gui/components/virtual-cell/components/ListItems.vue

@ -29,6 +29,8 @@ const { isSharedBase } = storeToRefs(useBase())
const filterQueryRef = ref() const filterQueryRef = ref()
const { t } = useI18n()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const { const {
@ -53,6 +55,8 @@ const { addLTARRef, isNew, removeLTARRef, state: rowState } = useSmartsheetRowSt
const isPublic = inject(IsPublicInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false))
const isExpandedFormCloseAfterSave = ref(false)
isChildrenExcludedLoading.value = true isChildrenExcludedLoading.value = true
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
@ -112,7 +116,8 @@ const newRowState = computed(() => {
if (isNew.value) return {} if (isNew.value) return {}
const colOpt = (injectedColumn?.value as ColumnType)?.colOptions as LinkToAnotherRecordType const colOpt = (injectedColumn?.value as ColumnType)?.colOptions as LinkToAnotherRecordType
const colInRelatedTable: ColumnType | undefined = relatedTableMeta?.value?.columns?.find((col) => { const colInRelatedTable: ColumnType | undefined = relatedTableMeta?.value?.columns?.find((col) => {
if (col.uidt !== UITypes.LinkToAnotherRecord) return false // Links as for the case of 'mm' we need the 'Links' column
if (!isLinksOrLTAR(col)) return false
const colOpt1 = col?.colOptions as LinkToAnotherRecordType const colOpt1 = col?.colOptions as LinkToAnotherRecordType
if (colOpt1?.fk_related_model_id !== meta.value.id) return false if (colOpt1?.fk_related_model_id !== meta.value.id) return false
@ -157,6 +162,10 @@ const relation = computed(() => {
watch(expandedFormDlg, () => { watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) { if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
if (!isForm.value) {
loadChildrenList()
}
loadChildrenExcludedList(rowState.value) loadChildrenExcludedList(rowState.value)
} }
}) })
@ -173,6 +182,42 @@ const onClick = (refRow: any, id: string) => {
linkRow(refRow, Number.parseInt(id)) linkRow(refRow, Number.parseInt(id))
} }
} }
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)
}
</script> </script>
<template> <template>
@ -191,14 +236,14 @@ const onClick = (refRow: any, id: string) => {
:table-title="meta?.title" :table-title="meta?.title"
:related-table-title="relatedTableMeta?.title" :related-table-title="relatedTableMeta?.title"
:display-value="row.row[displayValueProp]" :display-value="row.row[displayValueProp]"
:header="$t('activity.addNewLink')"
/> />
<div class="!xs:hidden my-3 bg-gray-50 border-gray-50 border-b-2"></div>
<div class="flex mt-2 mb-2 items-center gap-2"> <div class="flex mt-2 mb-2 items-center gap-2">
<div <div
class="flex items-center border-1 p-1 rounded-md w-full border-gray-200" class="flex items-center border-1 p-1 rounded-md w-full border-gray-200"
:class="{ '!border-primary': childrenExcludedListPagination.query.length !== 0 || isFocused }" :class="{ '!border-primary': childrenExcludedListPagination.query.length !== 0 || isFocused }"
> >
<MdiMagnify class="w-5 h-5 ml-2" /> <MdiMagnify class="w-5 h-5 ml-2 text-gray-500" />
<a-input <a-input
ref="filterQueryRef" ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query" v-model:value="childrenExcludedListPagination.query"
@ -223,12 +268,7 @@ const onClick = (refRow: any, id: string) => {
type="secondary" type="secondary"
:size="isMobileMode ? 'medium' : 'small'" :size="isMobileMode ? 'medium' : 'small'"
class="!text-brand-500" class="!text-brand-500"
@click=" @click="addNewRecord"
() => {
expandedFormRow = {}
expandedFormDlg = true
}
"
> >
<div class="flex items-center gap-1 px-4"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div> <div class="flex items-center gap-1 px-4"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div>
</NcButton> </NcButton>
@ -344,6 +384,15 @@ const onClick = (refRow: any, id: string) => {
: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
:close-after-save="isExpandedFormCloseAfterSave"
:new-record-header="
isExpandedFormCloseAfterSave
? $t('activity.tableNameCreateNewRecord', {
tableName: relatedTableMeta?.title,
})
: undefined
"
@created-record="onCreatedRecord"
/> />
</Suspense> </Suspense>
</NcModal> </NcModal>

2
packages/nc-gui/components/webhook/CallLog.vue

@ -89,7 +89,7 @@ onBeforeMount(async () => {
</span> </span>
<span> <span>
For additional configuration options, please refer the documentation For additional configuration options, please refer the documentation
<a href="https://docs.nocodb.com/developer-resources/webhooks#call-log" target="_blank">here</a>. <a href="https://docs.nocodb.com/developer-resources/webhooks#call-log" target="_blank" rel="noopener">here</a>.
</span> </span>
</a-card> </a-card>

1
packages/nc-gui/components/webhook/Drawer.vue

@ -50,6 +50,7 @@ async function addHook() {
href="https://angel.co/company/nocodb" href="https://angel.co/company/nocodb"
target="_blank" target="_blank"
size="large" size="large"
rel="noopener noreferrer"
> >
🚀 {{ $t('labels.weAreHiring') }}! 🚀 🚀 {{ $t('labels.weAreHiring') }}! 🚀
</a-button> </a-button>

2
packages/nc-gui/components/webhook/Editor.vue

@ -783,7 +783,7 @@ onMounted(async () => {
</div> </div>
<div class="my-3"> <div class="my-3">
<a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank"> <a href="https://docs.nocodb.com/developer-resources/webhooks/" target="_blank" rel="noopener">
<!-- Document Reference --> <!-- Document Reference -->
{{ $t('labels.docReference') }} {{ $t('labels.docReference') }}
</a> </a>

7
packages/nc-gui/components/workspace/Menu.vue

@ -7,7 +7,12 @@
data-testid="nc-workspace-menu" data-testid="nc-workspace-menu"
class="flex items-center nc-workspace-menu overflow-hidden py-1.25 pr-0.25 justify-center w-full ml-2" class="flex items-center nc-workspace-menu overflow-hidden py-1.25 pr-0.25 justify-center w-full ml-2"
> >
<a class="transition-all duration-200 transform w-24 min-w-10" href="https://github.com/nocodb/nocodb" target="_blank"> <a
class="transition-all duration-200 transform w-24 min-w-10"
href="https://github.com/nocodb/nocodb"
target="_blank"
rel="noopener noreferrer"
>
<img alt="NocoDB" src="~/assets/img/brand/nocodb.png" /> <img alt="NocoDB" src="~/assets/img/brand/nocodb.png" />
</a> </a>
<div class="flex flex-grow"></div> <div class="flex flex-grow"></div>

2
packages/nc-gui/components/workspace/ProjectList.vue

@ -14,10 +14,10 @@ import {
storeToRefs, storeToRefs,
useBases, useBases,
useGlobal, useGlobal,
useNuxtApp,
useRoles, useRoles,
useWorkspace, useWorkspace,
} from '#imports' } from '#imports'
import { useNuxtApp } from '#app'
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()

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

@ -50,6 +50,8 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const sqlUi = ref(meta.value?.source_id ? sqlUis.value[meta.value?.source_id] : Object.values(sqlUis.value)[0]) const sqlUi = ref(meta.value?.source_id ? sqlUis.value[meta.value?.source_id] : Object.values(sqlUis.value)[0])
const { activeView } = storeToRefs(useViewsStore())
const isEdit = computed(() => !!column?.value?.id) const isEdit = computed(() => !!column?.value?.id)
const isMysql = computed(() => isMysqlFunc(meta.value?.source_id ? meta.value?.source_id : Object.keys(sqlUis.value)[0])) const isMysql = computed(() => isMysqlFunc(meta.value?.source_id ? meta.value?.source_id : Object.keys(sqlUis.value)[0]))
@ -275,7 +277,11 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
// }; // };
// } // }
} }
await $api.dbTableColumn.create(meta.value?.id as string, { ...formState.value, ...columnPosition }) await $api.dbTableColumn.create(meta.value?.id as string, {
...formState.value,
...columnPosition,
view_id: activeView.value!.id as string,
})
/** if LTAR column then force reload related table meta */ /** if LTAR column then force reload related table meta */
if (isLinksOrLTAR(formState.value) && meta.value?.id !== formState.value.childId) { if (isLinksOrLTAR(formState.value) && meta.value?.id !== formState.value.childId) {

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

@ -91,7 +91,7 @@ export function useData(args: {
base?.value.id as string, base?.value.id as string,
metaValue?.id as string, metaValue?.id as string,
viewMetaValue?.id as string, viewMetaValue?.id as string,
insertObj, { ...insertObj, ...(ltarState || {}) },
) )
if (!undo) { if (!undo) {

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

@ -180,7 +180,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
if (missingRequiredColumns.size) return if (missingRequiredColumns.size) return
data = await $api.dbTableRow.create('noco', base.value.id as string, meta.value.id, insertObj) data = await $api.dbTableRow.create('noco', base.value.id as string, meta.value.id, {
...insertObj,
...(ltarState || {}),
})
Object.assign(row.value, { Object.assign(row.value, {
row: data, row: data,

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

@ -1,8 +1,7 @@
import { getActivePinia } from 'pinia' import { getActivePinia } from 'pinia'
import type { Actions, AppInfo, State } from './types' import type { Actions, AppInfo, State } from './types'
import type { NcProjectType } from '#imports' import type { NcProjectType } from '#imports'
import { message, useNuxtApp } from '#imports' import { message, navigateTo, useNuxtApp } from '#imports'
import { navigateTo } from '#app'
export function useGlobalActions(state: State): Actions { export function useGlobalActions(state: State): Actions {
const setIsMobileMode = (isMobileMode: boolean) => { const setIsMobileMode = (isMobileMode: boolean) => {

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

@ -16,7 +16,7 @@ export * from './types'
* *
* @example * @example
* ```js * ```js
* import { useNuxtApp } from '#app' * import { useNuxtApp } from '#imports'
* *
* const { $state } = useNuxtApp() * const { $state } = useNuxtApp()
* *

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

@ -67,6 +67,7 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
latestRelease: null, latestRelease: null,
hiddenRelease: null, hiddenRelease: null,
isMobileMode: null, isMobileMode: null,
lastOpenedWorkspaceId: null,
} }
/** saves a reactive state, any change to these values will write/delete to localStorage */ /** saves a reactive state, any change to these values will write/delete to localStorage */

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

@ -9,6 +9,10 @@ export interface AppInfo {
authType: 'jwt' | 'none' authType: 'jwt' | 'none'
connectToExternalDB: boolean connectToExternalDB: boolean
defaultLimit: number defaultLimit: number
defaultGroupByLimit: {
limitGroup: number
limitRecord: number
}
firstUser: boolean firstUser: boolean
githubAuthEnabled: boolean githubAuthEnabled: boolean
googleAuthEnabled: boolean googleAuthEnabled: boolean
@ -44,6 +48,7 @@ export interface StoredState {
latestRelease: string | null latestRelease: string | null
hiddenRelease: string | null hiddenRelease: string | null
isMobileMode: boolean | null isMobileMode: boolean | null
lastOpenedWorkspaceId: string | null
} }
export type State = ToRefs<Omit<StoredState, 'token'>> & { export type State = ToRefs<Omit<StoredState, 'token'>> & {

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

@ -114,6 +114,8 @@ export function useMultiSelect(
if (typeof textToCopy === 'object') { if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy) textToCopy = JSON.stringify(textToCopy)
} else {
textToCopy = textToCopy.toString()
} }
if (columnObj.uidt === UITypes.Formula) { if (columnObj.uidt === UITypes.Formula) {

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

@ -15,7 +15,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
) => { ) => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { activeView: view } = storeToRefs(useViewsStore()) const { activeView: view, activeNestedFilters, activeSorts } = storeToRefs(useViewsStore())
const baseStore = useBase() const baseStore = useBase()
@ -57,6 +57,26 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const sorts = ref<SortType[]>(unref(initialSorts) ?? []) const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? []) const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
watch(
sorts,
() => {
activeSorts.value = sorts.value
},
{
immediate: true,
},
)
watch(
nestedFilters,
() => {
activeNestedFilters.value = nestedFilters.value
},
{
immediate: true,
},
)
return { return {
view, view,
meta, meta,

30
packages/nc-gui/composables/useTableNew.ts

@ -113,17 +113,29 @@ export function useTableNew(param: { onTableCreate?: (tableMeta: TableType) => v
} }
const sqlUi = await basesStore.getSqlUi(baseId, sourceId) const sqlUi = await basesStore.getSqlUi(baseId, sourceId)
const source = bases.value.get(baseId)?.sources?.find((s) => s.id === sourceId)
if (!sqlUi) return if (!sqlUi) return
const columns = sqlUi?.getNewTableColumns().filter((col: ColumnType) => { const columns = sqlUi
if (col.column_name === 'id' && table.columns.includes('id_ag')) { ?.getNewTableColumns()
Object.assign(col, sqlUi?.getDataTypeForUiType({ uidt: UITypes.ID }, 'AG')) .filter((col: ColumnType) => {
col.dtxp = sqlUi?.getDefaultLengthForDatatype(col.dt) if (col.column_name === 'id' && table.columns.includes('id_ag')) {
col.dtxs = sqlUi?.getDefaultScaleForDatatype(col.dt) Object.assign(col, sqlUi?.getDataTypeForUiType({ uidt: UITypes.ID }, 'AG'))
return true col.dtxp = sqlUi?.getDefaultLengthForDatatype(col.dt)
} col.dtxs = sqlUi?.getDefaultScaleForDatatype(col.dt)
return table.columns.includes(col.column_name!) return true
}) }
return table.columns.includes(col.column_name!)
})
.map((column) => {
if (!source) return column
if (source.inflection_column !== 'camelize') {
column.title = column.column_name
}
return column
})
try { try {
const tableMeta = await $api.source.tableCreate(baseId, sourceId!, { const tableMeta = await $api.source.tableCreate(baseId, sourceId!, {

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

@ -6,6 +6,8 @@ import type { Group, GroupNestedIn, Row } from '#imports'
export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: ComputedRef<string | undefined>) => { export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: ComputedRef<string | undefined>) => {
const { api } = useApi() const { api } = useApi()
const { appInfo } = useGlobal()
const { base } = storeToRefs(useBase()) const { base } = storeToRefs(useBase())
const { sharedView, fetchSharedViewData } = useSharedView() const { sharedView, fetchSharedViewData } = useSharedView()
@ -42,7 +44,13 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const groupByLimit = 10 const groupByGroupLimit = computed(() => {
return appInfo.value.defaultGroupByLimit?.limitGroup || 10
})
const groupByRecordLimit = computed(() => {
return appInfo.value.defaultGroupByLimit?.limitRecord || 10
})
const rootGroup = ref<Group>({ const rootGroup = ref<Group>({
key: 'root', key: 'root',
@ -50,7 +58,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
count: 0, count: 0,
column: {} as any, column: {} as any,
nestedIn: [], nestedIn: [],
paginationData: { page: 1, pageSize: groupByLimit }, paginationData: { page: 1, pageSize: groupByGroupLimit.value },
nested: true, nested: true,
children: [], children: [],
root: true, root: true,
@ -64,7 +72,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
groupWrapper.paginationData.page = page groupWrapper.paginationData.page = page
await loadGroups( await loadGroups(
{ {
offset: (page - 1) * (groupWrapper.paginationData.pageSize || groupByLimit), offset: (page - 1) * (groupWrapper.paginationData.pageSize || groupByGroupLimit.value),
} as any, } as any,
groupWrapper, groupWrapper,
) )
@ -172,8 +180,8 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
const response = !isPublic.value const response = !isPublic.value
? await api.dbViewRow.groupBy('noco', base.value.id, view.value.fk_model_id, view.value.id, { ? await api.dbViewRow.groupBy('noco', base.value.id, view.value.fk_model_id, view.value.id, {
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByLimit), offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value),
limit: group.paginationData.pageSize ?? groupByLimit, limit: group.paginationData.pageSize ?? groupByGroupLimit.value,
...params, ...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }), ...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),
...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }), ...(isUIAllowed('filterSync') ? {} : { filterArrJson: JSON.stringify(nestedFilters.value) }),
@ -182,8 +190,8 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
column_name: groupby.column.title, column_name: groupby.column.title,
} as any) } as any)
: await api.public.dataGroupBy(sharedView.value!.uuid!, { : await api.public.dataGroupBy(sharedView.value!.uuid!, {
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByLimit), offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value),
limit: group.paginationData.pageSize ?? groupByLimit, limit: group.paginationData.pageSize ?? groupByGroupLimit.value,
...params, ...params,
where: nestedWhere, where: nestedWhere,
sort: `${groupby.sort === 'desc' ? '-' : ''}${groupby.column.title}`, sort: `${groupby.sort === 'desc' ? '-' : ''}${groupby.column.title}`,
@ -198,7 +206,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
) )
if (keyExists) { if (keyExists) {
keyExists.count += +curr.count keyExists.count += +curr.count
keyExists.paginationData = { page: 1, pageSize: groupByLimit, totalRows: keyExists.count } keyExists.paginationData = { page: 1, pageSize: groupByGroupLimit.value, totalRows: keyExists.count }
return acc return acc
} }
if (groupby.column.title && groupby.column.uidt) { if (groupby.column.title && groupby.column.uidt) {
@ -216,7 +224,11 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
column_uidt: groupby.column.uidt, column_uidt: groupby.column.uidt,
}, },
], ],
paginationData: { page: 1, pageSize: groupByLimit, totalRows: +curr.count }, paginationData: {
page: 1,
pageSize: group!.nestedIn.length < groupBy.value.length - 1 ? groupByGroupLimit.value : groupByRecordLimit.value,
totalRows: +curr.count,
},
nested: group!.nestedIn.length < groupBy.value.length - 1, nested: group!.nestedIn.length < groupBy.value.length - 1,
}) })
} }
@ -244,7 +256,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
// clear rest of the children // clear rest of the children
group.children = group.children.filter((c) => tempList.find((t) => t.key === c.key)) group.children = group.children.filter((c) => tempList.find((t) => t.key === c.key))
if (group.count <= (group.paginationData.pageSize ?? groupByLimit)) { if (group.count <= (group.paginationData.pageSize ?? groupByGroupLimit.value)) {
group.children.sort((a, b) => { group.children.sort((a, b) => {
const orderA = tempList.findIndex((t) => t.key === a.key) const orderA = tempList.findIndex((t) => t.key === a.key)
const orderB = tempList.findIndex((t) => t.key === b.key) const orderB = tempList.findIndex((t) => t.key === b.key)
@ -268,14 +280,14 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
if (group.children && !force) return if (group.children && !force) return
if (!group.paginationData) { if (!group.paginationData) {
group.paginationData = { page: 1, pageSize: groupByLimit } group.paginationData = { page: 1, pageSize: groupByRecordLimit.value }
} }
const nestedWhere = calculateNestedWhere(group.nestedIn, where?.value) const nestedWhere = calculateNestedWhere(group.nestedIn, where?.value)
const query = { const query = {
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByLimit), offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByRecordLimit.value),
limit: group.paginationData.pageSize ?? groupByLimit, limit: group.paginationData.pageSize ?? groupByRecordLimit.value,
where: `${nestedWhere}`, where: `${nestedWhere}`,
} }
@ -294,7 +306,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
const loadGroupPage = async (group: Group, p: number) => { const loadGroupPage = async (group: Group, p: number) => {
if (!group.paginationData) { if (!group.paginationData) {
group.paginationData = { page: 1, pageSize: groupByLimit } group.paginationData = { page: 1, pageSize: groupByRecordLimit.value }
} }
group.paginationData.page = p group.paginationData.page = p
await loadGroupData(group, true) await loadGroupData(group, true)
@ -331,7 +343,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
() => groupBy.value.length, () => groupBy.value.length,
async () => { async () => {
if (groupBy.value.length > 0) { if (groupBy.value.length > 0) {
rootGroup.value.paginationData = { page: 1, pageSize: groupByLimit } rootGroup.value.paginationData = { page: 1, pageSize: groupByGroupLimit.value }
rootGroup.value.column = {} as any rootGroup.value.column = {} as any
await loadGroups() await loadGroups()
refreshNested() refreshNested()

2
packages/nc-gui/helpers/parsers/JSONUrlTemplateAdapter.ts

@ -1,6 +1,6 @@
import type { Api } from 'nocodb-sdk' import type { Api } from 'nocodb-sdk'
import JSONTemplateAdapter from './JSONTemplateAdapter' import JSONTemplateAdapter from './JSONTemplateAdapter'
// import { useNuxtApp } from '#app' // import { useNuxtApp } from '#imports'
export default class JSONUrlTemplateAdapter extends JSONTemplateAdapter { export default class JSONUrlTemplateAdapter extends JSONTemplateAdapter {
url: string url: string

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

@ -69,6 +69,7 @@
"hex": "Hex", "hex": "Hex",
"clear": "Clear", "clear": "Clear",
"slack": "Slack", "slack": "Slack",
"comment": "Comment",
"microsoftTeams": "Microsoft Teams", "microsoftTeams": "Microsoft Teams",
"discord": "Discord", "discord": "Discord",
"matterMost": "Mattermost", "matterMost": "Mattermost",
@ -87,6 +88,7 @@
"action": "Action", "action": "Action",
"insert": "Insert", "insert": "Insert",
"delete": "Delete", "delete": "Delete",
"deleteEntity": "Delete {entity}",
"bulkInsert": "Bulk Insert", "bulkInsert": "Bulk Insert",
"bulkDelete": "Bulk Delete", "bulkDelete": "Bulk Delete",
"bulkUpdate": "Bulk Update", "bulkUpdate": "Bulk Update",
@ -342,9 +344,9 @@
"createBase": "Create Base", "createBase": "Create Base",
"myProject": "My Bases", "myProject": "My Bases",
"formTitle": "Form Title", "formTitle": "Form Title",
"collabView": "Collaborative View", "collaborative": "Collaborative",
"lockedView": "Locked View", "locked": "Locked",
"personalView": "Personal View", "personal": "Personal",
"appStore": "App Store", "appStore": "App Store",
"teamAndAuth": "Team & Auth", "teamAndAuth": "Team & Auth",
"rolesUserMgmt": "Roles & Users Management", "rolesUserMgmt": "Roles & Users Management",
@ -409,9 +411,11 @@
} }
}, },
"labels": { "labels": {
"downloadData": "Download Data",
"noToken": "No Token", "noToken": "No Token",
"tokenLimit": "Only one token per user is allowed", "tokenLimit": "Only one token per user is allowed",
"duplicateAttachment": "File with name {filename} already attached", "duplicateAttachment": "File with name {filename} already attached",
"viewIdColon": "VIEW ID: {viewId}",
"toAddress": "To Address", "toAddress": "To Address",
"subject": "Subject", "subject": "Subject",
"body": "Body", "body": "Body",
@ -419,6 +423,7 @@
"headerName": "Header Name", "headerName": "Header Name",
"icon": "Icon", "icon": "Icon",
"max": "Max", "max": "Max",
"idColon": "Id:",
"copiedRecordURL": "Copied Record URL", "copiedRecordURL": "Copied Record URL",
"copyRecordURL": "Copy Record URL", "copyRecordURL": "Copy Record URL",
"duplicateRecord": "Duplicate record", "duplicateRecord": "Duplicate record",
@ -443,7 +448,10 @@
"inUI": "in UI Dashboard", "inUI": "in UI Dashboard",
"projectSettings": "Base Settings", "projectSettings": "Base Settings",
"clickToHide": "Click to hide", "clickToHide": "Click to hide",
"clickToDownload": "Click to download",
"forRole": "for role", "forRole": "for role",
"clickToCopyViewID": "Click to copy View ID",
"viewMode": "View Mode",
"searchUsers": "Search Users", "searchUsers": "Search Users",
"superAdmin": "Super Admin", "superAdmin": "Super Admin",
"allTables": "All Tables", "allTables": "All Tables",
@ -619,6 +627,8 @@
"newFormLoaded": "New form will be loaded after" "newFormLoaded": "New form will be loaded after"
}, },
"activity": { "activity": {
"openInANewTab": "Open in a new tab",
"copyIFrameCode": "Copy IFrame code",
"onCondition": "On Condition", "onCondition": "On Condition",
"bulkDownload": "Bulk Download", "bulkDownload": "Bulk Download",
"attachFile": "Attach File", "attachFile": "Attach File",
@ -703,6 +713,8 @@
"inviteTeam": "Invite Team", "inviteTeam": "Invite Team",
"inviteUser": "Invite User", "inviteUser": "Invite User",
"inviteToken": "Invite Token", "inviteToken": "Invite Token",
"linkedRecords": "Linked Records",
"addNewLink": "Add New Link",
"newUser": "New User", "newUser": "New User",
"editUser": "Edit user", "editUser": "Edit user",
"deleteUser": "Remove user from base", "deleteUser": "Remove user from base",
@ -738,8 +750,8 @@
"deleteSelectedRow": "Delete Selected Records", "deleteSelectedRow": "Delete Selected Records",
"importExcel": "Import Excel", "importExcel": "Import Excel",
"importCSV": "Import CSV", "importCSV": "Import CSV",
"downloadCSV": "Download as CSV", "downloadCSV": "Download CSV",
"downloadExcel": "Download as XLSX", "downloadExcel": "Download Excel",
"uploadCSV": "Upload CSV", "uploadCSV": "Upload CSV",
"import": "Import", "import": "Import",
"importMetadata": "Import Metadata", "importMetadata": "Import Metadata",
@ -753,9 +765,10 @@
"fillByCodeScan": "Fill by scan", "fillByCodeScan": "Fill by scan",
"listSharedView": "Shared View List", "listSharedView": "Shared View List",
"ListView": "Views List", "ListView": "Views List",
"copyView": "Copy view", "copyView": "Copy View",
"renameView": "Rename view", "renameView": "Rename View",
"deleteView": "Delete view", "uploadData": "Upload Data",
"deleteView": "Delete View",
"createGrid": "Create Grid View", "createGrid": "Create Grid View",
"createGallery": "Create Gallery View", "createGallery": "Create Gallery View",
"createCalendar": "Create Calendar View", "createCalendar": "Create Calendar View",
@ -788,6 +801,9 @@
"linkRecord": "Link record", "linkRecord": "Link record",
"addNewRecord": "Add new record", "addNewRecord": "Add new record",
"newRecord": "New record", "newRecord": "New record",
"tableNameCreateNewRecord": "{tableName}: Create new record",
"gotSavedLinkedSuccessfully": "{tableName} '{recordTitle}' got saved & linked successfully",
"recordCreatedLinked": "Record Created & Linked",
"useConnectionUrl": "Use Connection URL", "useConnectionUrl": "Use Connection URL",
"toggleCommentsDraw": "Toggle comments draw", "toggleCommentsDraw": "Toggle comments draw",
"expandRecord": "Expand Record", "expandRecord": "Expand Record",
@ -901,6 +917,7 @@
"key": "Key" "key": "Key"
}, },
"msg": { "msg": {
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password", "enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the", "bySigningUp": "By signing up, you agree to the",
"subscribeToOurWeeklyNewsletter": "Subscribe to our weekly newsletter", "subscribeToOurWeeklyNewsletter": "Subscribe to our weekly newsletter",
@ -992,6 +1009,7 @@
"createWebhookMsg2": "Create web-hooks to power you automations,", "createWebhookMsg2": "Create web-hooks to power you automations,",
"createWebhookMsg3": "Get notified as soon as there are changes in your data", "createWebhookMsg3": "Get notified as soon as there are changes in your data",
"areYouSureUWantTo": "Are you sure you want to delete the following", "areYouSureUWantTo": "Are you sure you want to delete the following",
"areYouSureUWantToDeleteLabel": "Are you sure you want to {deleteLabel} the following",
"idColumnRequired": "ID field is required, you can rename this later if required.", "idColumnRequired": "ID field is required, you can rename this later if required.",
"length59Required": "The length exceeds the max 59 characters", "length59Required": "The length exceeds the max 59 characters",
"noNewNotifications": "You have no new notifications", "noNewNotifications": "You have no new notifications",
@ -1007,7 +1025,8 @@
}, },
"nonEditableFields": { "nonEditableFields": {
"computedFieldUnableToClear": "Warning: Computed field - unable to clear text", "computedFieldUnableToClear": "Warning: Computed field - unable to clear text",
"qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed." "qrFieldsCannotBeDirectlyChanged": "Warning: QR fields cannot be directly changed.",
"barcodeFieldsCannotBeDirectlyChanged": "Warning: Barcode fields cannot be directly changed."
}, },
"duplicateProject": "Are you sure you want to duplicate the base?", "duplicateProject": "Are you sure you want to duplicate the base?",
"duplicateTable": "Are you sure you want to duplicate the table?" "duplicateTable": "Are you sure you want to duplicate the table?"
@ -1178,6 +1197,7 @@
"nameMinLength": "Name must be at least 2 characters long", "nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long", "nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required", "viewNameRequired": "View name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique", "viewNameUnique": "View name should be unique",
"searchProject": "Your search for {search} found no results", "searchProject": "Your search for {search} found no results",
"invalidChar": "Invalid character in folder path.", "invalidChar": "Invalid character in folder path.",

6
packages/nc-gui/middleware/auth.global.ts

@ -133,11 +133,13 @@ async function tryGoogleAuth(api: Api<any>, signIn: Actions['signIn']) {
authProvider = 'oidc' authProvider = 'oidc'
} }
// `extra` prop is used in our cloud implementation, so we are keeping it
const { const {
data: { token, extra }, data: { token, extra },
} = await api.instance.post(`/auth/${authProvider}/genTokenByCode${window.location.search}`) } = await api.instance.post(`/auth/${authProvider}/genTokenByCode${window.location.search}`)
extraProps = extra // if extra prop is null/undefined set it as an empty object as fallback
extraProps = extra || {}
signIn(token) signIn(token)
} catch (e: any) { } catch (e: any) {
@ -148,7 +150,7 @@ async function tryGoogleAuth(api: Api<any>, signIn: Actions['signIn']) {
window.history.pushState( window.history.pushState(
'object', 'object',
document.title, document.title,
`${extraProps.continueAfterSignIn ? `${newURL}#/?continueAfterSignIn=${extraProps.continueAfterSignIn}` : newURL}`, `${extraProps?.continueAfterSignIn ? `${newURL}#/?continueAfterSignIn=${extraProps.continueAfterSignIn}` : newURL}`,
) )
window.location.reload() window.location.reload()
} }

2
packages/nc-gui/package.json

@ -143,7 +143,7 @@
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"happy-dom": "^6.0.3", "happy-dom": "^6.0.3",
"nuxt": "^3.6.5", "nuxt": "^3.8.1",
"nuxt-windicss": "^2.6.1", "nuxt-windicss": "^2.6.1",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"sass": "^1.63.4", "sass": "^1.63.4",

2
packages/nc-gui/pages/account/index.vue

@ -104,7 +104,7 @@ const logout = async () => {
<template #icon> <template #icon>
<MdiAccountSupervisorOutline /> <MdiAccountSupervisorOutline />
</template> </template>
<template #title>Users</template> <template #title>{{ $t('objects.users') }}</template>
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('superAdminUserManagement') && !isEeUI" v-if="isUIAllowed('superAdminUserManagement') && !isEeUI"

2
packages/nc-gui/pages/profile/[[username]].vue

@ -72,7 +72,7 @@ await loadProfile(route.params.username as string)
<div v-if="profile.website" class="nc-profile-website my-2"> <div v-if="profile.website" class="nc-profile-website my-2">
<div class="flex items-center mr-4"> <div class="flex items-center mr-4">
<MdiLinkVariant class="text-lg mr-2" /> <MdiLinkVariant class="text-lg mr-2" />
<a class="!no-underline" :href="profile.website" target="_blank">{{ profile.website }}</a> <a class="!no-underline" :href="profile.website" rel="noopener noreferrer" target="_blank">{{ profile.website }}</a>
</div> </div>
</div> </div>

2
packages/nc-gui/pages/signup/[[token]].vue

@ -238,7 +238,7 @@ onMounted(async () => {
<div class="prose-sm mt-4 text-gray-500"> <div class="prose-sm mt-4 text-gray-500">
{{ $t('msg.bySigningUp') }} {{ $t('msg.bySigningUp') }}
<a class="prose-sm !text-gray-500 underline" target="_blank" href="https://nocodb.com/policy-nocodb"> <a class="prose-sm !text-gray-500 underline" target="_blank" href="https://nocodb.com/policy-nocodb" rel="noopener">
{{ $t('title.termsOfService') }}</a {{ $t('title.termsOfService') }}</a
> >
</div> </div>

7
packages/nc-gui/plugins/a.dayjs.ts

@ -1,10 +1,11 @@
import { extend } from 'dayjs' import dayjs, { extend } from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime.js' import relativeTime from 'dayjs/plugin/relativeTime.js'
import customParseFormat from 'dayjs/plugin/customParseFormat.js' import customParseFormat from 'dayjs/plugin/customParseFormat.js'
import duration from 'dayjs/plugin/duration.js' import duration from 'dayjs/plugin/duration.js'
import utc from 'dayjs/plugin/utc.js' import utc from 'dayjs/plugin/utc.js'
import weekday from 'dayjs/plugin/weekday.js' import weekday from 'dayjs/plugin/weekday.js'
import timezone from 'dayjs/plugin/timezone.js' import timezone from 'dayjs/plugin/timezone.js'
import updateLocale from 'dayjs/plugin/updateLocale'
import { defineNuxtPlugin } from '#imports' import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
@ -14,4 +15,8 @@ export default defineNuxtPlugin(() => {
extend(duration) extend(duration)
extend(weekday) extend(weekday)
extend(timezone) extend(timezone)
extend(updateLocale)
dayjs.updateLocale('en', {
weekStart: 1,
})
}) })

2
packages/nc-gui/plugins/state.ts

@ -6,7 +6,7 @@ import { loadLocaleMessages, setI18nLanguage } from '~/plugins/a.i18n'
* *
* @example * @example
* ```js * ```js
* import { useNuxtApp } from '#app' * import { useNuxtApp } from '#imports'
* *
* const { $state } = useNuxtApp() * const { $state } = useNuxtApp()
* *

4
packages/nc-gui/store/config.ts

@ -13,6 +13,9 @@ export const useConfigStore = defineStore('configStore', () => {
const isViewPortMobile = () => width.value < MAX_WIDTH_FOR_MOBILE_MODE const isViewPortMobile = () => width.value < MAX_WIDTH_FOR_MOBILE_MODE
// When set to true expanded form will auto focus on comment input and state will be set to false after focussing
const isExpandedFormCommentMode = ref(false)
const isMobileMode = ref(isViewPortMobile()) const isMobileMode = ref(isViewPortMobile())
const projectPageTab = ref<'allTable' | 'collaborator' | 'data-source'>('allTable') const projectPageTab = ref<'allTable' | 'collaborator' | 'data-source'>('allTable')
@ -67,6 +70,7 @@ export const useConfigStore = defineStore('configStore', () => {
isViewPortMobile, isViewPortMobile,
handleSidebarOpenOnMobileForNonViews, handleSidebarOpenOnMobileForNonViews,
projectPageTab, projectPageTab,
isExpandedFormCommentMode,
} }
}) })

11
packages/nc-gui/store/sidebar.ts

@ -42,6 +42,16 @@ export const useSidebarStore = defineStore('sidebarStore', () => {
const leftSidebarWidth = computed(() => (width.value * mobileNormalizedSidebarSize.value) / 100) const leftSidebarWidth = computed(() => (width.value * mobileNormalizedSidebarSize.value) / 100)
const nonHiddenMobileSidebarSize = computed(() => {
if (isMobileMode.value) {
return 100
}
return leftSideBarSize.value.current ?? leftSideBarSize.value.old
})
const nonHiddenLeftSidebarWidth = computed(() => (width.value * nonHiddenMobileSidebarSize.value) / 100)
return { return {
isLeftSidebarOpen, isLeftSidebarOpen,
isRightSidebarOpen, isRightSidebarOpen,
@ -50,6 +60,7 @@ export const useSidebarStore = defineStore('sidebarStore', () => {
leftSidebarState, leftSidebarState,
leftSidebarWidth, leftSidebarWidth,
mobileNormalizedSidebarSize, mobileNormalizedSidebarSize,
nonHiddenLeftSidebarWidth,
} }
}) })

10
packages/nc-gui/store/views.ts

@ -1,4 +1,4 @@
import type { ViewType, ViewTypes } from 'nocodb-sdk' import type { FilterType, SortType, ViewType, ViewTypes } from 'nocodb-sdk'
import { acceptHMRUpdate, defineStore } from 'pinia' import { acceptHMRUpdate, defineStore } from 'pinia'
import type { ViewPageType } from '~/lib' import type { ViewPageType } from '~/lib'
@ -41,6 +41,12 @@ export const useViewsStore = defineStore('viewsStore', () => {
}, },
}) })
// Both are synced with `useSmartsheetStore` state
// Sort of active view
const activeSorts = ref<SortType[]>([])
// Filters of active view (used for local filters)
const activeNestedFilters = ref<FilterType[]>([])
const isViewsLoading = ref(true) const isViewsLoading = ref(true)
const isViewDataLoading = ref(true) const isViewDataLoading = ref(true)
const isPublic = computed(() => route.value.meta?.public) const isPublic = computed(() => route.value.meta?.public)
@ -304,6 +310,8 @@ export const useViewsStore = defineStore('viewsStore', () => {
navigateToView, navigateToView,
changeView, changeView,
removeFromRecentViews, removeFromRecentViews,
activeSorts,
activeNestedFilters,
} }
}) })

69
packages/nc-gui/utils/svgToPng.ts

@ -0,0 +1,69 @@
function copyStylesInline(destinationNode: any, sourceNode: any) {
const containerElements = ['svg', 'g']
for (let cd = 0; cd < destinationNode.childNodes.length; cd++) {
const child = destinationNode.childNodes[cd]
if (containerElements.includes(child.tagName)) {
copyStylesInline(child, sourceNode.childNodes[cd])
continue
}
const style = sourceNode.childNodes[cd].currentStyle || window.getComputedStyle(sourceNode.childNodes[cd])
if (style === 'undefined' || style == null) continue
for (let st = 0; st < style.length; st++) {
child.style.setProperty(style[st], style.getPropertyValue(style[st]))
}
}
}
function triggerDownload(imgURI: string, fileName: string) {
const evt = new MouseEvent('click', {
view: window,
bubbles: false,
cancelable: true,
})
const a = document.createElement('a')
a.setAttribute('download', fileName)
a.setAttribute('href', imgURI)
a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noopener,noreferrer')
a.dispatchEvent(evt)
}
function downloadSvg(svg: SVGGraphicsElement, fileName: string) {
const copy = svg.cloneNode(true)
copyStylesInline(copy, svg)
const canvas = document.createElement('canvas')
const bbox = svg.getBBox()
canvas.width = bbox.width
canvas.height = bbox.height
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, bbox.width, bbox.height)
const data = new XMLSerializer().serializeToString(copy)
const DOMURL = window.URL || window.webkitURL || window
const img = new Image()
const svgBlob = new Blob([data], { type: 'image/svg+xml;charset=utf-8' })
const url = DOMURL.createObjectURL(svgBlob)
img.onload = function () {
ctx.drawImage(img, 0, 0)
DOMURL.revokeObjectURL(url)
if (typeof navigator !== 'undefined' && navigator.msSaveOrOpenBlob) {
const blob = canvas.msToBlob()
navigator.msSaveOrOpenBlob(blob, fileName)
} else {
const imgURI = canvas.toDataURL('image/png').replace('image/png', 'image/octet-stream')
triggerDownload(imgURI, fileName)
}
console.log(canvas)
// TODO: Somehow canvas dom element is getting deleted
// document.removeChild(canvas)
}
img.src = url
}
export { downloadSvg }

3
packages/nc-gui/utils/urlUtils.ts

@ -13,6 +13,7 @@ export const replaceUrlsWithLink = (text: string): boolean | string => {
a.textContent = url a.textContent = url
a.setAttribute('href', url) a.setAttribute('href', url)
a.setAttribute('target', '_blank') a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noopener,noreferrer')
return a.outerHTML return a.outerHTML
}) })
@ -25,5 +26,5 @@ export const isValidURL = (str: string) => {
export const openLink = (path: string, baseURL?: string, target = '_blank') => { export const openLink = (path: string, baseURL?: string, target = '_blank') => {
const url = new URL(path, baseURL) const url = new URL(path, baseURL)
window.open(url.href, target) window.open(url.href, target, 'noopener,noreferrer')
} }

2
packages/noco-docs/docs/020.getting-started/050.self-hosted/020.environment-variables.md

@ -22,6 +22,8 @@ For production use-cases, it is **recommended** to configure
| NC_AUTH_JWT_SECRET | JWT secret used for auth and storing other secrets | A random secret will be generated | | NC_AUTH_JWT_SECRET | JWT secret used for auth and storing other secrets | A random secret will be generated |
| PORT | For setting app running port | `8080` | | PORT | For setting app running port | `8080` |
| DB_QUERY_LIMIT_DEFAULT | Pagination limit | 25 | | DB_QUERY_LIMIT_DEFAULT | Pagination limit | 25 |
| DB_QUERY_LIMIT_GROUP_BY_GROUP | Group per page limit | 10 |
| DB_QUERY_LIMIT_GROUP_BY_RECORD | Record per group limit | 10 |
| DB_QUERY_LIMIT_MAX | Maximum allowed pagination limit | 1000 | | DB_QUERY_LIMIT_MAX | Maximum allowed pagination limit | 1000 |
| DB_QUERY_LIMIT_MIN | Minimum allowed pagination limit | 1 | | DB_QUERY_LIMIT_MIN | Minimum allowed pagination limit | 1 |
| NC_TOOL_DIR | App directory to keep metadata and app related files | Defaults to current working directory. In docker maps to `/usr/app/data/` for mounting volume. | | NC_TOOL_DIR | App directory to keep metadata and app related files | Defaults to current working directory. In docker maps to `/usr/app/data/` for mounting volume. |

48
packages/nocodb-sdk/src/lib/Api.ts

@ -504,6 +504,7 @@ export type ColumnReqType = (
view_id?: string; view_id?: string;
}; };
title: string; title: string;
view_id?: string;
}; };
/** /**
@ -5745,6 +5746,53 @@ export class Api<
...params, ...params,
}), }),
/**
* @description Duplicate a column
*
* @tags DB Table
* @name DuplicateColumn
* @summary Duplicate Column
* @request POST:/api/v1/db/meta/duplicate/{baseId}/column/{columnId}
* @response `200` `{
name?: string,
id?: string,
}` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
duplicateColumn: (
baseId: IdType,
columnId: IdType,
data: {
options?: {
excludeData?: boolean;
};
extra?: object;
},
params: RequestParams = {}
) =>
this.request<
{
name?: string;
id?: string;
},
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v1/db/meta/duplicate/${baseId}/column/${columnId}`,
method: 'POST',
body: data,
type: ContentType.Json,
format: 'json',
...params,
}),
/** /**
* @description Update the order of the given Table * @description Update the order of the given Table
* *

85
packages/nocodb/src/Noco.ts

@ -9,7 +9,7 @@ import dotenv from 'dotenv';
import { IoAdapter } from '@nestjs/platform-socket.io'; import { IoAdapter } from '@nestjs/platform-socket.io';
import requestIp from 'request-ip'; import requestIp from 'request-ip';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { Logger } from '@nestjs/common'; import type { INestApplication } from '@nestjs/common';
import type { MetaService } from '~/meta/meta.service'; import type { MetaService } from '~/meta/meta.service';
import type { IEventEmitter } from '~/modules/event-emitter/event-emitter.interface'; import type { IEventEmitter } from '~/modules/event-emitter/event-emitter.interface';
import type { Express } from 'express'; import type { Express } from 'express';
@ -21,17 +21,16 @@ import { isEE } from '~/utils';
dotenv.config(); dotenv.config();
export default class Noco { export default class Noco {
private static _this: Noco; protected static _this: Noco;
private static ee: boolean; protected static ee: boolean;
public static readonly env: string = '_noco'; public static readonly env: string = '_noco';
private static _httpServer: http.Server; protected static _httpServer: http.Server;
private static _server: Express; protected static _server: Express;
private static logger = new Logger(Noco.name);
public static get dashboardUrl(): string { public static get dashboardUrl(): string {
const siteUrl = `http://localhost:${process.env.PORT || 8080}`; const siteUrl = `http://localhost:${process.env.PORT || 8080}`;
return `${siteUrl}${Noco._this?.config?.dashboardPath}`; return `${siteUrl}${this._this?.config?.dashboardPath}`;
} }
public static config: any; public static config: any;
@ -43,8 +42,8 @@ export default class Noco {
public readonly metaMgrv2: any; public readonly metaMgrv2: any;
public env: string; public env: string;
private config: any; protected config: any;
private requestContext: any; protected requestContext: any;
constructor() { constructor() {
process.env.PORT = process.env.PORT || '8080'; process.env.PORT = process.env.PORT || '8080';
@ -82,77 +81,79 @@ export default class Noco {
} }
public static getConfig(): any { public static getConfig(): any {
return Noco.config; return this.config;
} }
public static isEE(): boolean { public static isEE(): boolean {
return Noco.ee || process.env.NC_CLOUD === 'true'; return this.ee || process.env.NC_CLOUD === 'true';
} }
public static async loadEEState(): Promise<boolean> { public static async loadEEState(): Promise<boolean> {
try { try {
return (Noco.ee = isEE); return (this.ee = isEE);
} catch {} } catch {}
return (Noco.ee = false); return (this.ee = false);
} }
static async init(param: any, httpServer: http.Server, server: Express) { static async init(param: any, httpServer: http.Server, server: Express) {
const nestApp = await NestFactory.create(AppModule); const nestApp = await NestFactory.create(AppModule, {
bufferLogs: true,
});
this.initCustomLogger(nestApp);
nestApp.flushLogs();
if (process.env.NC_WORKER_CONTAINER === 'true') { if (process.env.NC_WORKER_CONTAINER === 'true') {
if (!process.env.NC_REDIS_URL) { if (!process.env.NC_REDIS_URL) {
throw new Error('NC_REDIS_URL is required'); throw new Error('NC_REDIS_URL is required');
} }
process.env.NC_DISABLE_TELE = 'true'; process.env.NC_DISABLE_TELE = 'true';
}
nestApp.init(); nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
} else {
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
this._httpServer = nestApp.getHttpAdapter().getInstance();
this._server = server;
nestApp.use(requestIp.mw()); this._httpServer = nestApp.getHttpAdapter().getInstance();
nestApp.use(cookieParser()); this._server = server;
nestApp.useWebSocketAdapter(new IoAdapter(httpServer)); nestApp.use(requestIp.mw());
nestApp.use(cookieParser());
nestApp.use( nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);
await nestApp.init(); nestApp.use(
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);
const dashboardPath = process.env.NC_DASHBOARD_URL ?? '/dashboard'; await nestApp.init();
server.use(NcToolGui.expressMiddleware(dashboardPath));
server.use(express.static(path.join(__dirname, 'public')));
if (dashboardPath !== '/' && dashboardPath !== '') { const dashboardPath = process.env.NC_DASHBOARD_URL ?? '/dashboard';
server.get('/', (_req, res) => res.redirect(dashboardPath)); server.use(NcToolGui.expressMiddleware(dashboardPath));
} server.use(express.static(path.join(__dirname, 'public')));
return nestApp.getHttpAdapter().getInstance(); if (dashboardPath !== '/' && dashboardPath !== '') {
server.get('/', (_req, res) => res.redirect(dashboardPath));
} }
return nestApp.getHttpAdapter().getInstance();
} }
public static get httpServer(): http.Server { public static get httpServer(): http.Server {
return Noco._httpServer; return this._httpServer;
} }
public static get server(): Express { public static get server(): Express {
return Noco._server; return this._server;
} }
public static async initJwt(): Promise<any> { public static async initJwt(): Promise<any> {
if (this.config?.auth?.jwt) { if (this.config?.auth?.jwt) {
if (!this.config.auth.jwt.secret) { if (!this.config.auth.jwt.secret) {
let secret = ( let secret = (
await Noco._ncMeta.metaGet('', '', MetaTable.STORE, { await this._ncMeta.metaGet('', '', MetaTable.STORE, {
key: 'nc_auth_jwt_secret', key: 'nc_auth_jwt_secret',
}) })
)?.value; )?.value;
if (!secret) { if (!secret) {
await Noco._ncMeta.metaInsert('', '', MetaTable.STORE, { await this._ncMeta.metaInsert('', '', MetaTable.STORE, {
key: 'nc_auth_jwt_secret', key: 'nc_auth_jwt_secret',
value: (secret = uuidv4()), value: (secret = uuidv4()),
}); });
@ -167,16 +168,20 @@ export default class Noco {
} }
} }
let serverId = ( let serverId = (
await Noco._ncMeta.metaGet('', '', MetaTable.STORE, { await this._ncMeta.metaGet('', '', MetaTable.STORE, {
key: 'nc_server_id', key: 'nc_server_id',
}) })
)?.value; )?.value;
if (!serverId) { if (!serverId) {
await Noco._ncMeta.metaInsert('', '', MetaTable.STORE, { await this._ncMeta.metaInsert('', '', MetaTable.STORE, {
key: 'nc_server_id', key: 'nc_server_id',
value: (serverId = T.id), value: (serverId = T.id),
}); });
} }
process.env.NC_SERVER_UUID = serverId; process.env.NC_SERVER_UUID = serverId;
} }
protected static initCustomLogger(_nestApp: INestApplication<any>) {
// setup custom logger for nestjs if needed
}
} }

11
packages/nocodb/src/controllers/api-tokens.controller.ts

@ -6,9 +6,10 @@ import {
HttpCode, HttpCode,
Param, Param,
Post, Post,
Request, Req,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express';
import { GlobalGuard } from '~/guards/global/global.guard'; import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse'; import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { ApiTokensService } from '~/services/api-tokens.service'; import { ApiTokensService } from '~/services/api-tokens.service';
@ -25,7 +26,7 @@ export class ApiTokensController {
'/api/v2/meta/bases/:baseId/api-tokens', '/api/v2/meta/bases/:baseId/api-tokens',
]) ])
@Acl('baseApiTokenList') @Acl('baseApiTokenList')
async apiTokenList(@Request() req) { async apiTokenList(@Req() req: Request) {
return new PagedResponseImpl( return new PagedResponseImpl(
await this.apiTokensService.apiTokenList({ userId: req['user'].id }), await this.apiTokensService.apiTokenList({ userId: req['user'].id }),
); );
@ -37,10 +38,11 @@ export class ApiTokensController {
]) ])
@HttpCode(200) @HttpCode(200)
@Acl('baseApiTokenCreate') @Acl('baseApiTokenCreate')
async apiTokenCreate(@Request() req, @Body() body) { async apiTokenCreate(@Req() req: Request, @Body() body) {
return await this.apiTokensService.apiTokenCreate({ return await this.apiTokensService.apiTokenCreate({
tokenBody: body, tokenBody: body,
userId: req['user'].id, userId: req['user'].id,
req,
}); });
} }
@ -49,10 +51,11 @@ export class ApiTokensController {
'/api/v2/meta/bases/:baseId/api-tokens/:token', '/api/v2/meta/bases/:baseId/api-tokens/:token',
]) ])
@Acl('baseApiTokenDelete') @Acl('baseApiTokenDelete')
async apiTokenDelete(@Request() req, @Param('token') token: string) { async apiTokenDelete(@Req() req: Request, @Param('token') token: string) {
return await this.apiTokensService.apiTokenDelete({ return await this.apiTokensService.apiTokenDelete({
token, token,
user: req['user'], user: req['user'],
req,
}); });
} }
} }

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

Loading…
Cancel
Save