Browse Source

Merge branch 'develop' into feat/yyyy-mm

pull/6870/head
աɨռɢӄաօռɢ 8 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">
<g id="check">
<path id="Vector" d="M13.3333 4.5L5.99996 11.8333L2.66663 8.5" stroke="#40444D" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector" d="M13.3333 4.5L5.99996 11.8333L2.66663 8.5" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 275 B

After

Width:  |  Height:  |  Size: 280 B

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

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

Before

Width:  |  Height:  |  Size: 725 B

After

Width:  |  Height:  |  Size: 735 B

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

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

Before

Width:  |  Height:  |  Size: 458 B

After

Width:  |  Height:  |  Size: 473 B

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

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

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

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

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

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

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

@ -29,7 +29,6 @@ interface Props {
}
const { modelValue, isPk } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
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
const updateOpen = (next: boolean) => {
if (open.value && !next) {
@ -236,7 +243,7 @@ const clickHandler = () => {
:allow-clear="!readOnly && !localState && !isPk"
:input-read-only="true"
:dropdown-class-name="`${randomClass} nc-picker-date ${open ? 'active' : ''}`"
:open="((readOnly || (localState && isPk)) && !active && !editable) || isLockedMode ? false : open"
:open="isOpen"
@click="clickHandler"
@update:open="updateOpen"
>

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

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

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

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

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

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

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

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

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>
<a v-else-if="validEmail" class="text-sm underline hover:opacity-75" :href="`tel:${vModel}`" target="_blank">
<a
v-else-if="validEmail"
class="text-sm underline hover:opacity-75"
:href="`tel:${vModel}`"
target="_blank"
rel="noopener noreferrer"
>
<LazyCellClampedText :value="vModel" :lines="rowHeight" />
</a>

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

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

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>
<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>

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

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

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

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

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

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

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

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

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

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

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

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

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 { storeToRefs } from 'pinia'
import { useNuxtApp } from '#app'
import { ProjectRoleInj, TreeViewInj, useRoles, useTabs } from '#imports'
import { ProjectRoleInj, TreeViewInj, useNuxtApp, useRoles, useTabs } from '#imports'
const props = withDefaults(
defineProps<{

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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,
useGlobal,
useI18n,
useNuxtApp,
useVModel,
} from '#imports'
// import worker script according to the doc of Vite
import importWorkerUrl from '~/workers/importWorker?worker&url'
import { useNuxtApp } from '#app'
interface Props {
modelValue: boolean

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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 { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const commentsWrapperEl = ref<HTMLDivElement>()
const { user, appInfo } = useGlobal()
@ -27,6 +29,8 @@ const editLog = ref<AuditType>()
const isEditing = ref<boolean>(false)
const commentInputDomRef = ref<HTMLInputElement>()
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
function onKeyDown(event: KeyboardEvent) {
@ -124,6 +128,15 @@ const onClickAudit = () => {
tab.value = 'audits'
}
watch(commentInputDomRef, () => {
if (commentInputDomRef.value && isExpandedFormCommentMode.value) {
setTimeout(() => {
commentInputDomRef.value?.focus()
isExpandedFormCommentMode.value = false
}, 400)
}
})
</script>
<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">
<GeneralUserIcon size="base" class="!w-10" :email="user?.email" :name="user?.display_name" />
<a-input
ref="commentInputDomRef"
v-model:value="comment"
class="!rounded-lg border-1 bg-white !px-2.5 !py-2 !border-gray-200 nc-comment-box !outline-none"
placeholder="Start typing..."
data-testid="expanded-form-comment-input"
:bordered="false"
@keyup.enter.prevent="saveComment"
>

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

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

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 { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
const {
predictingNextColumn,
predictedNextColumn,
@ -179,7 +181,7 @@ const gridRect = useElementBounding(gridWrapper)
// #Permissions
const { isUIAllowed } = useRoles()
const hasEditPermission = computed(() => isUIAllowed('dataEdit'))
const hasEditPermission = computed(() => isUIAllowed('dataEdit') && !isLocked.value)
const isAddingColumnAllowed = computed(() => !readOnly.value && !isLocked.value && isUIAllowed('fieldAdd') && !isSqlView.value)
const { onDrag, onDragStart, draggedCol, dragColPlaceholderDomRef, toBeDroppedColId } = useColumnDrag({
@ -221,9 +223,7 @@ const _contextMenu = ref(false)
const contextMenu = computed({
get: () => _contextMenu.value,
set: (val) => {
if (hasEditPermission.value) {
_contextMenu.value = val
}
_contextMenu.value = val
},
})
const contextMenuClosing = ref(false)
@ -385,7 +385,7 @@ const gridWrapperClass = computed<string>(() => {
const classes = []
if (headerOnly !== true) {
if (!scrollParent.value) {
classes.push('nc-scrollbar-x-lg overflow-auto')
classes.push('nc-scrollbar-x-lg !overflow-auto')
}
} else {
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 = () => {
deleteRangeOfRows?.(selectedRange).then(() => {
clearSelectedRange()
@ -1253,13 +1270,14 @@ onKeyStroke('ArrowDown', onDown)
:trigger="isSqlView ? [] : ['contextmenu']"
overlay-class-name="nc-dropdown-grid-context-menu"
>
<div class="table-overlay" :class="{ 'nc-grid-skelton-loader': showSkeleton }">
<div class="table-overlay" :class="{ 'nc-grid-skeleton-loader': showSkeleton }">
<table
ref="smartTable"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative pr-60 pb-12"
class="xc-row-table nc-grid backgroundColorDefault !h-auto bg-white relative"
:class="{
mobile: isMobileMode,
desktop: !isMobileMode,
'mobile': isMobileMode,
'desktop': !isMobileMode,
'pr-60 pb-12': !headerOnly,
}"
@contextmenu="showContextMenu"
>
@ -1529,14 +1547,12 @@ onKeyStroke('ArrowDown', onDown)
<SmartsheetTableDataCell
v-for="(columnObj, colIndex) of fields"
:key="columnObj.id"
class="cell relative nc-grid-cell"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'cursor-pointer': hasEditPermission,
'active': hasEditPermission && isCellSelected(rowIndex, colIndex),
'active': isCellSelected(rowIndex, colIndex),
'active-cell':
hasEditPermission &&
((activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex)),
(activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex),
'last-cell':
rowIndex === (isNaN(selectedRange.end.row) ? activeCell.row : selectedRange.end.row) &&
colIndex === (isNaN(selectedRange.end.col) ? activeCell.col : selectedRange.end.col),
@ -1574,7 +1590,7 @@ onKeyStroke('ArrowDown', onDown)
:column="columnObj"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:row="row"
:read-only="readOnly"
:read-only="!hasEditPermission"
@navigate="onNavigate"
@save="updateOrSaveRow?.(row, '', state)"
/>
@ -1588,7 +1604,7 @@ onKeyStroke('ArrowDown', onDown)
"
:row-index="rowIndex"
:active="activeCell.col === colIndex && activeCell.row === rowIndex"
:read-only="readOnly"
:read-only="!hasEditPermission"
@update:edit-enabled="editEnabled = $event"
@save="updateOrSaveRow?.(row, columnObj.title, state)"
@navigate="onNavigate"
@ -1638,7 +1654,7 @@ onKeyStroke('ArrowDown', onDown)
/>
</div>
<template v-if="!isLocked && hasEditPermission" #overlay>
<template #overlay>
<NcMenu class="!rounded !py-0" @click="contextMenu = false">
<NcMenuItem
v-if="isEeUI && !contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)"
@ -1689,6 +1705,7 @@ onKeyStroke('ArrowDown', onDown)
<NcMenuItem
v-if="
contextMenuTarget &&
hasEditPermission &&
selectedRange.isSingleCell() &&
(isLinksOrLTAR(fields[contextMenuTarget.col]) || !isVirtualCol(fields[contextMenuTarget.col]))
"
@ -1702,7 +1719,7 @@ onKeyStroke('ArrowDown', onDown)
<!-- Clear cell -->
<NcMenuItem
v-else-if="contextMenuTarget"
v-else-if="contextMenuTarget && hasEditPermission"
v-e="['a:row:clear-range']"
class="nc-base-menu-item"
@click="clearSelectedRangeOfCells()"
@ -1711,28 +1728,40 @@ onKeyStroke('ArrowDown', onDown)
{{ $t('general.clear') }}
</NcMenuItem>
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />
<NcMenuItem
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="confirmDeleteRow(contextMenuTarget.row)"
<template
v-if="contextMenuTarget && !isLocked && selectedRange.isSingleCell() && isUIAllowed('commentEdit') && !isMobileMode"
>
<GeneralIcon icon="delete" />
<!-- Delete Row -->
{{ $t('activity.deleteRow') }}
</NcMenuItem>
<div v-else-if="contextMenuTarget && deleteRangeOfRows">
<NcDivider />
<NcMenuItem v-e="['a:row:comment']" class="nc-base-menu-item" @click="commentRow(contextMenuTarget.row)">
<MdiMessageOutline class="h-4 w-4" />
{{ $t('general.comment') }}
</NcMenuItem>
</template>
<template v-if="hasEditPermission">
<NcDivider v-if="!(!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected))" />
<NcMenuItem
v-if="contextMenuTarget && (selectedRange.isSingleCell() || selectedRange.isSingleRow())"
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="deleteSelectedRangeOfRows"
@click="confirmDeleteRow(contextMenuTarget.row)"
>
<GeneralIcon icon="delete" class="text-gray-500 text-red-600" />
<!-- Delete Rows -->
{{ $t('activity.deleteRows') }}
<GeneralIcon icon="delete" />
<!-- Delete Row -->
{{ $t('activity.deleteRow') }}
</NcMenuItem>
</div>
<div v-else-if="contextMenuTarget && deleteRangeOfRows">
<NcMenuItem
v-e="['a:row:delete']"
class="nc-base-menu-item !text-red-600 !hover:bg-red-50"
@click="deleteSelectedRangeOfRows"
>
<GeneralIcon icon="delete" class="text-gray-500 text-red-600" />
<!-- Delete Rows -->
{{ $t('activity.deleteRows') }}
</NcMenuItem>
</div>
</template>
</NcMenu>
</template>
</NcDropdown>
@ -1971,7 +2000,7 @@ onKeyStroke('ArrowDown', onDown)
}
}
.nc-grid-skelton-loader {
.nc-grid-skeleton-loader {
thead th:nth-child(2) {
@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>
import type { ColumnReqType } from 'nocodb-sdk'
import type { ColumnReqType, ColumnType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR } from 'nocodb-sdk'
import {
ActiveViewInj,
@ -8,8 +8,6 @@ import {
MetaInj,
ReloadViewDataHookInj,
SmartsheetStoreEvents,
extractSdkResponseErrorMsg,
getUniqueColumnName,
iconMap,
inject,
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 = {}
// generate duplicate column name
const duplicateColumnName = getUniqueColumnName(`${column!.value.title}_copy`, meta!.value!.columns!)
// construct column create payload
switch (column?.value.uidt) {
case UITypes.LinkToAnotherRecord:
case UITypes.Links:
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:
return message.info('Not available at the moment')
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
// generate duplicate column title
const duplicateColumnTitle = getUniqueColumnName(`${column!.value.title} copy`, meta!.value!.columns!)
columnCreatePayload = {
...column!.value!,
...(column!.value.colOptions ?? {}),
title: duplicateColumnTitle,
column_name: duplicateColumnTitle.replace(/\s/g, '_'),
id: undefined,
colOptions: undefined,
order: undefined,
}
try {
@ -170,9 +144,10 @@ const duplicateColumn = async () => {
await $api.dbTableColumn.create(meta!.value!.id!, {
...columnCreatePayload,
pv: false,
view_id: view.value!.id as string,
column_order: {
order: newColumnOrder,
view_id: view.value?.id as string,
view_id: view.value!.id as string,
},
} as ColumnReqType)
await getMeta(meta!.value!.id!, true)
@ -188,6 +163,35 @@ const duplicateColumn = async () => {
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
const addColumn = async (before = false) => {
const gridViewColumnList = (await $api.dbViewColumn.list(view.value?.id as string)).list
@ -334,10 +338,7 @@ const onInsertAfter = () => {
<a-divider class="!my-0" />
<a-menu-item
v-if="column.uidt !== UITypes.LinkToAnotherRecord && column.uidt !== UITypes.Lookup && !column.pk"
@click="duplicateColumn"
>
<a-menu-item v-if="!column?.pk" @click="openDuplicateDlg">
<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" />
<!-- Duplicate -->
@ -371,6 +372,12 @@ const onInsertAfter = () => {
</template>
</a-dropdown>
<SmartsheetHeaderDeleteColumnModal v-model:visible="showDeleteColumnModal" />
<DlgColumnDuplicate
v-if="selectedColumnToDuplicate"
v-model="isDuplicateDlgOpen"
:column="selectedColumnToDuplicate"
:extra="selectedColumnExtra"
/>
</template>
<style scoped>

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

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

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

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

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

@ -113,7 +113,7 @@ onMounted(() => {
<div :class="{ 'nc-active-btn': sorts?.length }">
<a-button v-e="['c:sort']" class="nc-sort-menu-btn nc-toolbar-btn" :disabled="isLocked">
<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 -->
<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">
const { isMobileMode } = useGlobal()
const { openedViewsTab, activeView } = storeToRefs(useViewsStore())
const { activeView } = storeToRefs(useViewsStore())
const { base, isSharedBase } = storeToRefs(useBase())
const { baseUrl } = useBase()
@ -43,7 +43,9 @@ const openedBaseUrl = computed(() => {
>
<NcTooltip class="!text-inherit">
<template #title>
{{ base?.title }}
<span class="capitalize">
{{ base?.title }}
</span>
</template>
<div class="flex flex-row items-center gap-x-1.5">
<GeneralProjectIcon
@ -59,14 +61,14 @@ const openedBaseUrl = computed(() => {
'!flex': isSharedBase && !isMobileMode,
}"
>
<span class="truncate !text-inherit">
<span class="truncate !text-inherit capitalize">
{{ base?.title }}
</span>
</div>
</div>
</NcTooltip>
</NuxtLink>
<div class="px-1.5 text-gray-500">/</div>
<div class="px-1.75 text-gray-500">/</div>
</template>
<template v-if="!(isMobileMode && !activeView?.is_default)">
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeTable?.meta?.icon" readonly size="xsmall">
@ -119,7 +121,7 @@ const openedBaseUrl = computed(() => {
</div>
</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)">
<LazyGeneralEmojiPicker v-if="isMobileMode" :emoji="activeView?.meta?.icon" readonly size="xsmall">
@ -128,30 +130,7 @@ const openedBaseUrl = computed(() => {
</template>
</LazyGeneralEmojiPicker>
<NcTooltip
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>
<SmartsheetToolbarOpenedViewAction />
</template>
<LazySmartsheetToolbarReload v-if="openedViewsTab === 'view' && !isMobileMode" />
</div>
</template>

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

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

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

@ -59,8 +59,20 @@ const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning } = us
@ok="handleModalOkClick"
>
<template #footer>
<div class="mr-4 overflow-scroll p-2" data-testid="nc-qr-code-large-value-label">
{{ qrValue }}
<div class="flex flex-row">
<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>
</template>
<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"
@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>
<div
v-if="!tooManyCharsForBarcode"
class="flex ml-2 w-full items-center"
:class="{
'justify-start': isExpandedFormOpen,

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

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

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'
const { relation, relatedTableTitle, displayValue, showHeader, tableTitle } = defineProps<{
const { relation, relatedTableTitle, displayValue, header, tableTitle } = defineProps<{
relation: string
showHeader?: boolean
header?: string | null
tableTitle: string
relatedTableTitle: string
displayValue?: string
@ -54,12 +54,12 @@ const relationMeta = computed(() => {
<template>
<div class="flex sm:justify-between relative pb-2 items-center">
<div v-if="!isMobileMode" class="flex text-base font-bold justify-start items-center min-w-36">
{{ showHeader ? 'Linked Records' : '' }}
{{ header ?? '' }}
</div>
<div class="flex flex-row sm:w-[calc(100%-16rem)] xs:w-full items-center justify-center gap-2 xs:(h-full)">
<div class="flex sm:justify-end w-[calc(50%-1.5rem)] xs:(w-[calc(50%-1.5rem)] h-full)">
<div
class="flex max-w-full xs:w-full flex-shrink-0 xs:(h-full) rounded-md gap-1 text-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" />
<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
*/
const skeltonCount = computed(() => {
const skeletonCount = computed(() => {
if (props.items < 10 && childrenListPagination.page === 1) {
return props.items
}
@ -192,7 +192,7 @@ const linkOrUnLink = (rowRef: Record<string, string>, id: string) => {
:relation="relation"
:linked-records="childrenListCount"
:table-title="meta?.title"
:show-header="true"
:header="$t('activity.linkedRecords')"
:related-table-title="relatedTableMeta?.title"
:display-value="row.row[displayValueProp]"
/>
@ -222,7 +222,7 @@ const linkOrUnLink = (rowRef: Record<string, string>, id: string) => {
<div class="cursor-pointer pr-1">
<template v-if="isChildrenLoading">
<div
v-for="(x, i) in Array.from({ length: skeltonCount })"
v-for="(x, i) in Array.from({ length: skeletonCount })"
:key="i"
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 { t } = useI18n()
const { $e } = useNuxtApp()
const {
@ -53,6 +55,8 @@ const { addLTARRef, isNew, removeLTARRef, state: rowState } = useSmartsheetRowSt
const isPublic = inject(IsPublicInj, ref(false))
const isExpandedFormCloseAfterSave = ref(false)
isChildrenExcludedLoading.value = true
const isForm = inject(IsFormInj, ref(false))
@ -112,7 +116,8 @@ const newRowState = computed(() => {
if (isNew.value) return {}
const colOpt = (injectedColumn?.value as ColumnType)?.colOptions as LinkToAnotherRecordType
const colInRelatedTable: ColumnType | undefined = relatedTableMeta?.value?.columns?.find((col) => {
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
if (colOpt1?.fk_related_model_id !== meta.value.id) return false
@ -157,6 +162,10 @@ const relation = computed(() => {
watch(expandedFormDlg, () => {
if (!expandedFormDlg.value) {
isExpandedFormCloseAfterSave.value = false
if (!isForm.value) {
loadChildrenList()
}
loadChildrenExcludedList(rowState.value)
}
})
@ -173,6 +182,42 @@ const onClick = (refRow: any, id: string) => {
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>
<template>
@ -191,14 +236,14 @@ const onClick = (refRow: any, id: string) => {
:table-title="meta?.title"
:related-table-title="relatedTableMeta?.title"
: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 items-center border-1 p-1 rounded-md w-full border-gray-200"
: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
ref="filterQueryRef"
v-model:value="childrenExcludedListPagination.query"
@ -223,12 +268,7 @@ const onClick = (refRow: any, id: string) => {
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
class="!text-brand-500"
@click="
() => {
expandedFormRow = {}
expandedFormDlg = true
}
"
@click="addNewRecord"
>
<div class="flex items-center gap-1 px-4"><MdiPlus v-if="!isMobileMode" /> {{ $t('activity.newRecord') }}</div>
</NcButton>
@ -344,6 +384,15 @@ const onClick = (refRow: any, id: string) => {
:row-id="extractPkFromRow(expandedFormRow, relatedTableMeta.columns as ColumnType[])"
:state="newRowState"
use-meta-fields
:close-after-save="isExpandedFormCloseAfterSave"
:new-record-header="
isExpandedFormCloseAfterSave
? $t('activity.tableNameCreateNewRecord', {
tableName: relatedTableMeta?.title,
})
: undefined
"
@created-record="onCreatedRecord"
/>
</Suspense>
</NcModal>

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

@ -89,7 +89,7 @@ onBeforeMount(async () => {
</span>
<span>
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>
</a-card>

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

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

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

@ -783,7 +783,7 @@ onMounted(async () => {
</div>
<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 -->
{{ $t('labels.docReference') }}
</a>

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

@ -7,7 +7,12 @@
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"
>
<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" />
</a>
<div class="flex flex-grow"></div>

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

@ -14,10 +14,10 @@ import {
storeToRefs,
useBases,
useGlobal,
useNuxtApp,
useRoles,
useWorkspace,
} from '#imports'
import { useNuxtApp } from '#app'
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 { activeView } = storeToRefs(useViewsStore())
const isEdit = computed(() => !!column?.value?.id)
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 (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,
metaValue?.id as string,
viewMetaValue?.id as string,
insertObj,
{ ...insertObj, ...(ltarState || {}) },
)
if (!undo) {

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

@ -180,7 +180,10 @@ const [useProvideExpandedFormStore, useExpandedFormStore] = useInjectionState((m
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, {
row: data,

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

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

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

@ -16,7 +16,7 @@ export * from './types'
*
* @example
* ```js
* import { useNuxtApp } from '#app'
* import { useNuxtApp } from '#imports'
*
* 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,
hiddenRelease: null,
isMobileMode: null,
lastOpenedWorkspaceId: null,
}
/** 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'
connectToExternalDB: boolean
defaultLimit: number
defaultGroupByLimit: {
limitGroup: number
limitRecord: number
}
firstUser: boolean
githubAuthEnabled: boolean
googleAuthEnabled: boolean
@ -44,6 +48,7 @@ export interface StoredState {
latestRelease: string | null
hiddenRelease: string | null
isMobileMode: boolean | null
lastOpenedWorkspaceId: string | null
}
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') {
textToCopy = JSON.stringify(textToCopy)
} else {
textToCopy = textToCopy.toString()
}
if (columnObj.uidt === UITypes.Formula) {

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

@ -15,7 +15,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
) => {
const { $api } = useNuxtApp()
const { activeView: view } = storeToRefs(useViewsStore())
const { activeView: view, activeNestedFilters, activeSorts } = storeToRefs(useViewsStore())
const baseStore = useBase()
@ -57,6 +57,26 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
watch(
sorts,
() => {
activeSorts.value = sorts.value
},
{
immediate: true,
},
)
watch(
nestedFilters,
() => {
activeNestedFilters.value = nestedFilters.value
},
{
immediate: true,
},
)
return {
view,
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 source = bases.value.get(baseId)?.sources?.find((s) => s.id === sourceId)
if (!sqlUi) return
const columns = sqlUi?.getNewTableColumns().filter((col: ColumnType) => {
if (col.column_name === 'id' && table.columns.includes('id_ag')) {
Object.assign(col, sqlUi?.getDataTypeForUiType({ uidt: UITypes.ID }, 'AG'))
col.dtxp = sqlUi?.getDefaultLengthForDatatype(col.dt)
col.dtxs = sqlUi?.getDefaultScaleForDatatype(col.dt)
return true
}
return table.columns.includes(col.column_name!)
})
const columns = sqlUi
?.getNewTableColumns()
.filter((col: ColumnType) => {
if (col.column_name === 'id' && table.columns.includes('id_ag')) {
Object.assign(col, sqlUi?.getDataTypeForUiType({ uidt: UITypes.ID }, 'AG'))
col.dtxp = sqlUi?.getDefaultLengthForDatatype(col.dt)
col.dtxs = sqlUi?.getDefaultScaleForDatatype(col.dt)
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 {
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>) => {
const { api } = useApi()
const { appInfo } = useGlobal()
const { base } = storeToRefs(useBase())
const { sharedView, fetchSharedViewData } = useSharedView()
@ -42,7 +44,13 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
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>({
key: 'root',
@ -50,7 +58,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
count: 0,
column: {} as any,
nestedIn: [],
paginationData: { page: 1, pageSize: groupByLimit },
paginationData: { page: 1, pageSize: groupByGroupLimit.value },
nested: true,
children: [],
root: true,
@ -64,7 +72,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
groupWrapper.paginationData.page = page
await loadGroups(
{
offset: (page - 1) * (groupWrapper.paginationData.pageSize || groupByLimit),
offset: (page - 1) * (groupWrapper.paginationData.pageSize || groupByGroupLimit.value),
} as any,
groupWrapper,
)
@ -172,8 +180,8 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
const response = !isPublic.value
? 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),
limit: group.paginationData.pageSize ?? groupByLimit,
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value),
limit: group.paginationData.pageSize ?? groupByGroupLimit.value,
...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.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,
} as any)
: await api.public.dataGroupBy(sharedView.value!.uuid!, {
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByLimit),
limit: group.paginationData.pageSize ?? groupByLimit,
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByGroupLimit.value),
limit: group.paginationData.pageSize ?? groupByGroupLimit.value,
...params,
where: nestedWhere,
sort: `${groupby.sort === 'desc' ? '-' : ''}${groupby.column.title}`,
@ -198,7 +206,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
)
if (keyExists) {
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
}
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,
},
],
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,
})
}
@ -244,7 +256,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
// clear rest of the children
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) => {
const orderA = tempList.findIndex((t) => t.key === a.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.paginationData) {
group.paginationData = { page: 1, pageSize: groupByLimit }
group.paginationData = { page: 1, pageSize: groupByRecordLimit.value }
}
const nestedWhere = calculateNestedWhere(group.nestedIn, where?.value)
const query = {
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByLimit),
limit: group.paginationData.pageSize ?? groupByLimit,
offset: ((group.paginationData.page ?? 0) - 1) * (group.paginationData.pageSize ?? groupByRecordLimit.value),
limit: group.paginationData.pageSize ?? groupByRecordLimit.value,
where: `${nestedWhere}`,
}
@ -294,7 +306,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
const loadGroupPage = async (group: Group, p: number) => {
if (!group.paginationData) {
group.paginationData = { page: 1, pageSize: groupByLimit }
group.paginationData = { page: 1, pageSize: groupByRecordLimit.value }
}
group.paginationData.page = p
await loadGroupData(group, true)
@ -331,7 +343,7 @@ export const useViewGroupBy = (view: Ref<ViewType | undefined>, where?: Computed
() => groupBy.value.length,
async () => {
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
await loadGroups()
refreshNested()

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

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

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

@ -69,6 +69,7 @@
"hex": "Hex",
"clear": "Clear",
"slack": "Slack",
"comment": "Comment",
"microsoftTeams": "Microsoft Teams",
"discord": "Discord",
"matterMost": "Mattermost",
@ -87,6 +88,7 @@
"action": "Action",
"insert": "Insert",
"delete": "Delete",
"deleteEntity": "Delete {entity}",
"bulkInsert": "Bulk Insert",
"bulkDelete": "Bulk Delete",
"bulkUpdate": "Bulk Update",
@ -342,9 +344,9 @@
"createBase": "Create Base",
"myProject": "My Bases",
"formTitle": "Form Title",
"collabView": "Collaborative View",
"lockedView": "Locked View",
"personalView": "Personal View",
"collaborative": "Collaborative",
"locked": "Locked",
"personal": "Personal",
"appStore": "App Store",
"teamAndAuth": "Team & Auth",
"rolesUserMgmt": "Roles & Users Management",
@ -409,9 +411,11 @@
}
},
"labels": {
"downloadData": "Download Data",
"noToken": "No Token",
"tokenLimit": "Only one token per user is allowed",
"duplicateAttachment": "File with name {filename} already attached",
"viewIdColon": "VIEW ID: {viewId}",
"toAddress": "To Address",
"subject": "Subject",
"body": "Body",
@ -419,6 +423,7 @@
"headerName": "Header Name",
"icon": "Icon",
"max": "Max",
"idColon": "Id:",
"copiedRecordURL": "Copied Record URL",
"copyRecordURL": "Copy Record URL",
"duplicateRecord": "Duplicate record",
@ -443,7 +448,10 @@
"inUI": "in UI Dashboard",
"projectSettings": "Base Settings",
"clickToHide": "Click to hide",
"clickToDownload": "Click to download",
"forRole": "for role",
"clickToCopyViewID": "Click to copy View ID",
"viewMode": "View Mode",
"searchUsers": "Search Users",
"superAdmin": "Super Admin",
"allTables": "All Tables",
@ -619,6 +627,8 @@
"newFormLoaded": "New form will be loaded after"
},
"activity": {
"openInANewTab": "Open in a new tab",
"copyIFrameCode": "Copy IFrame code",
"onCondition": "On Condition",
"bulkDownload": "Bulk Download",
"attachFile": "Attach File",
@ -703,6 +713,8 @@
"inviteTeam": "Invite Team",
"inviteUser": "Invite User",
"inviteToken": "Invite Token",
"linkedRecords": "Linked Records",
"addNewLink": "Add New Link",
"newUser": "New User",
"editUser": "Edit user",
"deleteUser": "Remove user from base",
@ -738,8 +750,8 @@
"deleteSelectedRow": "Delete Selected Records",
"importExcel": "Import Excel",
"importCSV": "Import CSV",
"downloadCSV": "Download as CSV",
"downloadExcel": "Download as XLSX",
"downloadCSV": "Download CSV",
"downloadExcel": "Download Excel",
"uploadCSV": "Upload CSV",
"import": "Import",
"importMetadata": "Import Metadata",
@ -753,9 +765,10 @@
"fillByCodeScan": "Fill by scan",
"listSharedView": "Shared View List",
"ListView": "Views List",
"copyView": "Copy view",
"renameView": "Rename view",
"deleteView": "Delete view",
"copyView": "Copy View",
"renameView": "Rename View",
"uploadData": "Upload Data",
"deleteView": "Delete View",
"createGrid": "Create Grid View",
"createGallery": "Create Gallery View",
"createCalendar": "Create Calendar View",
@ -788,6 +801,9 @@
"linkRecord": "Link record",
"addNewRecord": "Add 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",
"toggleCommentsDraw": "Toggle comments draw",
"expandRecord": "Expand Record",
@ -901,6 +917,7 @@
"key": "Key"
},
"msg": {
"clickToCopyFieldId": "Click to copy Field Id",
"enterPassword": "Enter password",
"bySigningUp": "By signing up, you agree to the",
"subscribeToOurWeeklyNewsletter": "Subscribe to our weekly newsletter",
@ -992,6 +1009,7 @@
"createWebhookMsg2": "Create web-hooks to power you automations,",
"createWebhookMsg3": "Get notified as soon as there are changes in your data",
"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.",
"length59Required": "The length exceeds the max 59 characters",
"noNewNotifications": "You have no new notifications",
@ -1007,7 +1025,8 @@
},
"nonEditableFields": {
"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?",
"duplicateTable": "Are you sure you want to duplicate the table?"
@ -1178,6 +1197,7 @@
"nameMinLength": "Name must be at least 2 characters long",
"nameMaxLength": "Name must be at most 60 characters long",
"viewNameRequired": "View name is required",
"nameMaxLength256": "Name must be at most 256 characters long",
"viewNameUnique": "View name should be unique",
"searchProject": "Your search for {search} found no results",
"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'
}
// `extra` prop is used in our cloud implementation, so we are keeping it
const {
data: { token, extra },
} = 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)
} catch (e: any) {
@ -148,7 +150,7 @@ async function tryGoogleAuth(api: Api<any>, signIn: Actions['signIn']) {
window.history.pushState(
'object',
document.title,
`${extraProps.continueAfterSignIn ? `${newURL}#/?continueAfterSignIn=${extraProps.continueAfterSignIn}` : newURL}`,
`${extraProps?.continueAfterSignIn ? `${newURL}#/?continueAfterSignIn=${extraProps.continueAfterSignIn}` : newURL}`,
)
window.location.reload()
}

2
packages/nc-gui/package.json

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

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

@ -104,7 +104,7 @@ const logout = async () => {
<template #icon>
<MdiAccountSupervisorOutline />
</template>
<template #title>Users</template>
<template #title>{{ $t('objects.users') }}</template>
<NcMenuItem
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 class="flex items-center mr-4">
<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>

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

@ -238,7 +238,7 @@ onMounted(async () => {
<div class="prose-sm mt-4 text-gray-500">
{{ $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
>
</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 customParseFormat from 'dayjs/plugin/customParseFormat.js'
import duration from 'dayjs/plugin/duration.js'
import utc from 'dayjs/plugin/utc.js'
import weekday from 'dayjs/plugin/weekday.js'
import timezone from 'dayjs/plugin/timezone.js'
import updateLocale from 'dayjs/plugin/updateLocale'
import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin(() => {
@ -14,4 +15,8 @@ export default defineNuxtPlugin(() => {
extend(duration)
extend(weekday)
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
* ```js
* import { useNuxtApp } from '#app'
* import { useNuxtApp } from '#imports'
*
* 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
// 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 projectPageTab = ref<'allTable' | 'collaborator' | 'data-source'>('allTable')
@ -67,6 +70,7 @@ export const useConfigStore = defineStore('configStore', () => {
isViewPortMobile,
handleSidebarOpenOnMobileForNonViews,
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 nonHiddenMobileSidebarSize = computed(() => {
if (isMobileMode.value) {
return 100
}
return leftSideBarSize.value.current ?? leftSideBarSize.value.old
})
const nonHiddenLeftSidebarWidth = computed(() => (width.value * nonHiddenMobileSidebarSize.value) / 100)
return {
isLeftSidebarOpen,
isRightSidebarOpen,
@ -50,6 +60,7 @@ export const useSidebarStore = defineStore('sidebarStore', () => {
leftSidebarState,
leftSidebarWidth,
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 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 isViewDataLoading = ref(true)
const isPublic = computed(() => route.value.meta?.public)
@ -304,6 +310,8 @@ export const useViewsStore = defineStore('viewsStore', () => {
navigateToView,
changeView,
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.setAttribute('href', url)
a.setAttribute('target', '_blank')
a.setAttribute('rel', 'noopener,noreferrer')
return a.outerHTML
})
@ -25,5 +26,5 @@ export const isValidURL = (str: string) => {
export const openLink = (path: string, baseURL?: string, target = '_blank') => {
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 |
| PORT | For setting app running port | `8080` |
| 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_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. |

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

@ -504,6 +504,7 @@ export type ColumnReqType = (
view_id?: string;
};
title: string;
view_id?: string;
};
/**
@ -5745,6 +5746,53 @@ export class Api<
...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
*

85
packages/nocodb/src/Noco.ts

@ -9,7 +9,7 @@ import dotenv from 'dotenv';
import { IoAdapter } from '@nestjs/platform-socket.io';
import requestIp from 'request-ip';
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 { IEventEmitter } from '~/modules/event-emitter/event-emitter.interface';
import type { Express } from 'express';
@ -21,17 +21,16 @@ import { isEE } from '~/utils';
dotenv.config();
export default class Noco {
private static _this: Noco;
private static ee: boolean;
protected static _this: Noco;
protected static ee: boolean;
public static readonly env: string = '_noco';
private static _httpServer: http.Server;
private static _server: Express;
private static logger = new Logger(Noco.name);
protected static _httpServer: http.Server;
protected static _server: Express;
public static get dashboardUrl(): string {
const siteUrl = `http://localhost:${process.env.PORT || 8080}`;
return `${siteUrl}${Noco._this?.config?.dashboardPath}`;
return `${siteUrl}${this._this?.config?.dashboardPath}`;
}
public static config: any;
@ -43,8 +42,8 @@ export default class Noco {
public readonly metaMgrv2: any;
public env: string;
private config: any;
private requestContext: any;
protected config: any;
protected requestContext: any;
constructor() {
process.env.PORT = process.env.PORT || '8080';
@ -82,77 +81,79 @@ export default class Noco {
}
public static getConfig(): any {
return Noco.config;
return this.config;
}
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> {
try {
return (Noco.ee = isEE);
return (this.ee = isEE);
} catch {}
return (Noco.ee = false);
return (this.ee = false);
}
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_REDIS_URL) {
throw new Error('NC_REDIS_URL is required');
}
process.env.NC_DISABLE_TELE = 'true';
}
nestApp.init();
} else {
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
this._httpServer = nestApp.getHttpAdapter().getInstance();
this._server = server;
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
nestApp.use(requestIp.mw());
nestApp.use(cookieParser());
this._httpServer = nestApp.getHttpAdapter().getInstance();
this._server = server;
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
nestApp.use(requestIp.mw());
nestApp.use(cookieParser());
nestApp.use(
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
await nestApp.init();
nestApp.use(
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);
const dashboardPath = process.env.NC_DASHBOARD_URL ?? '/dashboard';
server.use(NcToolGui.expressMiddleware(dashboardPath));
server.use(express.static(path.join(__dirname, 'public')));
await nestApp.init();
if (dashboardPath !== '/' && dashboardPath !== '') {
server.get('/', (_req, res) => res.redirect(dashboardPath));
}
const dashboardPath = process.env.NC_DASHBOARD_URL ?? '/dashboard';
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 {
return Noco._httpServer;
return this._httpServer;
}
public static get server(): Express {
return Noco._server;
return this._server;
}
public static async initJwt(): Promise<any> {
if (this.config?.auth?.jwt) {
if (!this.config.auth.jwt.secret) {
let secret = (
await Noco._ncMeta.metaGet('', '', MetaTable.STORE, {
await this._ncMeta.metaGet('', '', MetaTable.STORE, {
key: 'nc_auth_jwt_secret',
})
)?.value;
if (!secret) {
await Noco._ncMeta.metaInsert('', '', MetaTable.STORE, {
await this._ncMeta.metaInsert('', '', MetaTable.STORE, {
key: 'nc_auth_jwt_secret',
value: (secret = uuidv4()),
});
@ -167,16 +168,20 @@ export default class Noco {
}
}
let serverId = (
await Noco._ncMeta.metaGet('', '', MetaTable.STORE, {
await this._ncMeta.metaGet('', '', MetaTable.STORE, {
key: 'nc_server_id',
})
)?.value;
if (!serverId) {
await Noco._ncMeta.metaInsert('', '', MetaTable.STORE, {
await this._ncMeta.metaInsert('', '', MetaTable.STORE, {
key: 'nc_server_id',
value: (serverId = T.id),
});
}
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,
Param,
Post,
Request,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { GlobalGuard } from '~/guards/global/global.guard';
import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { ApiTokensService } from '~/services/api-tokens.service';
@ -25,7 +26,7 @@ export class ApiTokensController {
'/api/v2/meta/bases/:baseId/api-tokens',
])
@Acl('baseApiTokenList')
async apiTokenList(@Request() req) {
async apiTokenList(@Req() req: Request) {
return new PagedResponseImpl(
await this.apiTokensService.apiTokenList({ userId: req['user'].id }),
);
@ -37,10 +38,11 @@ export class ApiTokensController {
])
@HttpCode(200)
@Acl('baseApiTokenCreate')
async apiTokenCreate(@Request() req, @Body() body) {
async apiTokenCreate(@Req() req: Request, @Body() body) {
return await this.apiTokensService.apiTokenCreate({
tokenBody: body,
userId: req['user'].id,
req,
});
}
@ -49,10 +51,11 @@ export class ApiTokensController {
'/api/v2/meta/bases/:baseId/api-tokens/:token',
])
@Acl('baseApiTokenDelete')
async apiTokenDelete(@Request() req, @Param('token') token: string) {
async apiTokenDelete(@Req() req: Request, @Param('token') token: string) {
return await this.apiTokensService.apiTokenDelete({
token,
user: req['user'],
req,
});
}
}

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

Loading…
Cancel
Save