Browse Source

feat: field, table and view descriptions (#9256)

* feat: table description wip

* fix: swagger update

* fix: wip descriptions view

* feat: field, view, table descriptions

* fix: failing tests

* fix: allow description edit for schema read-only sources

* fix: add missing condition

* fix: ux changes fix: duplicate service didn't copy descriptions

* fix: long text default value update

* fix: add new line for long text

* fix: include labels for table and view description update

* fix: workaround without breaking all tests

* fix: update swagger types and tests fix

* fix: source restriction tests

* fix: pr review changes

* fix: updated icons

* fix: updated tooltip positions fix: minor corrections

* fix: invalid description length

* fix: update focus on tables

* fix: add shared view descriptions

* fix: title is missing
pull/9285/head
Anbarasu 3 months ago committed by GitHub
parent
commit
44ef0dc485
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      packages/nc-gui/assets/nc-icons/align-left.svg
  2. 11
      packages/nc-gui/assets/nc-icons/calendar.svg
  3. 8
      packages/nc-gui/assets/nc-icons/form.svg
  4. 5
      packages/nc-gui/assets/nc-icons/gallery.svg
  5. 6
      packages/nc-gui/assets/nc-icons/grid.svg
  6. 6
      packages/nc-gui/assets/nc-icons/kanban.svg
  7. 272
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  8. 18
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  9. 44
      packages/nc-gui/components/dashboard/TreeView/index.vue
  10. 108
      packages/nc-gui/components/dlg/TableCreate.vue
  11. 182
      packages/nc-gui/components/dlg/TableDescriptionUpdate.vue
  12. 91
      packages/nc-gui/components/dlg/ViewCreate.vue
  13. 170
      packages/nc-gui/components/dlg/ViewDescriptionUpdate.vue
  14. 2
      packages/nc-gui/components/nc/Button.vue
  15. 73
      packages/nc-gui/components/nc/Tooltip.vue
  16. 4
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  17. 147
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  18. 4
      packages/nc-gui/components/smartsheet/column/EditOrAddProvider.vue
  19. 4
      packages/nc-gui/components/smartsheet/column/RichLongTextDefaultValue.vue
  20. 24
      packages/nc-gui/components/smartsheet/header/Cell.vue
  21. 123
      packages/nc-gui/components/smartsheet/header/Menu.vue
  22. 17
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  23. 25
      packages/nc-gui/components/smartsheet/toolbar/OpenedViewAction.vue
  24. 14
      packages/nc-gui/components/smartsheet/toolbar/ViewActionMenu.vue
  25. 1
      packages/nc-gui/composables/useColumnCreateStore.ts
  26. 3
      packages/nc-gui/composables/useTableNew.ts
  27. 2
      packages/nc-gui/composables/useViewAggregate.ts
  28. 2
      packages/nc-gui/context/index.ts
  29. 6
      packages/nc-gui/lang/en.json
  30. 17
      packages/nc-gui/layouts/shared-view.vue
  31. 1
      packages/nc-gui/lib/acl.ts
  32. 22
      packages/nc-gui/utils/iconUtils.ts
  33. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  34. 28
      packages/nocodb/src/meta/migrations/v2/nc_060_descriptions.ts
  35. 4
      packages/nocodb/src/models/Column.ts
  36. 16
      packages/nocodb/src/models/Model.ts
  37. 4
      packages/nocodb/src/models/View.ts
  38. 3
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  39. 29
      packages/nocodb/src/schema/swagger.json
  40. 31
      packages/nocodb/src/services/columns.service.ts
  41. 6
      packages/nocodb/src/services/tables.service.ts
  42. 10
      packages/nocodb/tests/unit/rest/tests/typeCasts.test.ts
  43. 4
      tests/playwright/tests/db/general/sourceRestrictions.spec.ts

6
packages/nc-gui/assets/nc-icons/align-left.svg

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.3333 12H2" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 9.33301H2" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.3333 6.66699H2" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 4H2" stroke="#1F293A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 554 B

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

@ -0,0 +1,11 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6667 2.66666H3.33333C2.59695 2.66666 2 3.26361 2 3.99999V13.3333C2 14.0697 2.59695 14.6667 3.33333 14.6667H12.6667C13.403 14.6667 14 14.0697 14 13.3333V3.99999C14 3.26361 13.403 2.66666 12.6667 2.66666Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.83331 9.12625V9.22625" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.83331 11.8V11.9" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 9.13V9.23" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 11.8V11.9" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.1667 9.13V9.23" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 6.66666H14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.6667 1.33334V4.00001" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.33331 1.33334V4.00001" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

8
packages/nc-gui/assets/nc-icons/form.svg

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.6667 2.66666H12C12.3536 2.66666 12.6928 2.80713 12.9428 3.05718C13.1929 3.30723 13.3334 3.64637 13.3334 3.99999V13.3333C13.3334 13.6869 13.1929 14.0261 12.9428 14.2761C12.6928 14.5262 12.3536 14.6667 12 14.6667H4.00002C3.6464 14.6667 3.30726 14.5262 3.05721 14.2761C2.80716 14.0261 2.66669 13.6869 2.66669 13.3333V3.99999C2.66669 3.64637 2.80716 3.30723 3.05721 3.05718C3.30726 2.80713 3.6464 2.66666 4.00002 2.66666H5.33335" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.99998 1.33334H5.99998C5.63179 1.33334 5.33331 1.63182 5.33331 2.00001V3.33334C5.33331 3.70153 5.63179 4.00001 5.99998 4.00001H9.99998C10.3682 4.00001 10.6666 3.70153 10.6666 3.33334V2.00001C10.6666 1.63182 10.3682 1.33334 9.99998 1.33334Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 7L11 7" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 7L5.1 7" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 10L11 10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 10L5.1 10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

5
packages/nc-gui/assets/nc-icons/gallery.svg

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 9.99999L10.6666 6.66666L3.33331 14" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.66669 6.66666C6.21897 6.66666 6.66669 6.21894 6.66669 5.66666C6.66669 5.11437 6.21897 4.66666 5.66669 4.66666C5.1144 4.66666 4.66669 5.11437 4.66669 5.66666C4.66669 6.21894 5.1144 6.66666 5.66669 6.66666Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 828 B

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

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 9.33334H9.33331V14H14V9.33334Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66667 9.33334H2V14H6.66667V9.33334Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 2H9.33331V6.66667H14V2Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.66667 2H2V6.66667H6.66667V2Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 653 B

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

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

After

Width:  |  Height:  |  Size: 716 B

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

@ -45,7 +45,12 @@ const { copy } = useCopy()
const baseRole = inject(ProjectRoleInj)
provide(SidebarTableInj, table)
const { setMenuContext, openRenameTableDialog: _openRenameTableDialog, duplicateTable: _duplicateTable } = inject(TreeViewInj)!
const {
setMenuContext,
openRenameTableDialog: _openRenameTableDialog,
openTableDescriptionDialog: _openTableDescriptionDialog,
duplicateTable: _duplicateTable,
} = inject(TreeViewInj)!
const { loadViews: _loadViews, navigateToView } = useViewsStore()
const { activeView, activeViewTitleOrId, viewsByTable } = storeToRefs(useViewsStore())
@ -204,6 +209,11 @@ const openRenameTableDialog = (table: SidebarTableNode, sourceId: string) => {
_openRenameTableDialog(table, !!sourceId)
}
const openTableDescriptionDialog = (table: SidebarTableNode) => {
isOptionsOpen.value = false
_openTableDescriptionDialog(table)
}
const deleteTable = () => {
isOptionsOpen.value = false
isTableDeleteDialogVisible.value = true
@ -343,124 +353,164 @@ const source = computed(() => {
{{ table.title }}
</span>
</NcTooltip>
<NcDropdown v-model:visible="isOptionsOpen" :trigger="['click']" @click.stop>
<NcButton
v-e="['c:table:option']"
class="nc-sidebar-node-btn nc-tbl-context-menu text-gray-700 hover:text-gray-800"
:class="{
'!opacity-100 !inline-block': isOptionsOpen,
}"
data-testid="nc-sidebar-table-context-menu"
type="text"
size="xxsmall"
@click.stop
>
<MdiDotsHorizontal class="!text-current" />
</NcButton>
<template #overlay>
<NcMenu class="!min-w-62.5" :data-testid="`sidebar-table-context-menu-list-${table.title}`">
<NcTooltip>
<template #title> {{ $t('labels.clickToCopyTableID') }} </template>
<div
class="flex items-center justify-between p-2 mx-1.5 rounded-md cursor-pointer hover:bg-gray-100 group"
@click.stop="onTableIdCopy"
>
<div class="flex text-xs font-bold text-gray-500 ml-1">
{{
$t('labels.tableIdColon', {
tableId: table?.id,
})
}}
</div>
<NcButton class="!group-hover:bg-gray-100" size="xsmall" type="secondary">
<GeneralIcon v-if="isTableIdCopied" class="max-h-4 min-w-4" icon="check" />
<GeneralIcon v-else class="max-h-4 min-w-4" else icon="copy" />
</NcButton>
</div>
</NcTooltip>
<template
v-if="
!isSharedBase &&
(isUIAllowed('tableRename', { roles: baseRole, source }) ||
isUIAllowed('tableDelete', { roles: baseRole, source }))
"
>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableRename', { roles: baseRole, source })"
:data-testid="`sidebar-table-rename-${table.title}`"
class="nc-table-rename"
@click="openRenameTableDialog(table, source.id)"
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.table').toLowerCase() }}
<div class="flex items-center">
<NcTooltip v-if="table.description?.length" placement="bottom">
<template #title>
{{ table.description }}
</template>
<NcButton type="text" class="!hover:bg-transparent" size="xsmall">
<GeneralIcon icon="info" class="!w-3.5 !h-3.5 nc-info-icon group-hover:opacity-100 text-gray-600 opacity-0" />
</NcButton>
</NcTooltip>
<NcDropdown v-model:visible="isOptionsOpen" :trigger="['click']" @click.stop>
<NcButton
v-e="['c:table:option']"
class="nc-sidebar-node-btn nc-tbl-context-menu text-gray-700 hover:text-gray-800"
:class="{
'!opacity-100 !inline-block': isOptionsOpen,
}"
data-testid="nc-sidebar-table-context-menu"
type="text"
size="xxsmall"
@click.stop
>
<MdiDotsHorizontal class="!text-current" />
</NcButton>
<template #overlay>
<NcMenu class="!min-w-62.5" :data-testid="`sidebar-table-context-menu-list-${table.title}`">
<NcTooltip>
<template #title> {{ $t('labels.clickToCopyTableID') }} </template>
<div
class="flex items-center justify-between p-2 mx-1.5 rounded-md cursor-pointer hover:bg-gray-100 group"
@click.stop="onTableIdCopy"
>
<div class="flex text-xs font-bold text-gray-500 ml-1">
{{
$t('labels.tableIdColon', {
tableId: table?.id,
})
}}
</div>
<NcButton class="!group-hover:bg-gray-100" size="xsmall" type="secondary">
<GeneralIcon v-if="isTableIdCopied" class="max-h-4 min-w-4" icon="check" />
<GeneralIcon v-else class="max-h-4 min-w-4" else icon="copy" />
</NcButton>
</div>
</NcMenuItem>
</NcTooltip>
<NcMenuItem
v-if="
isUIAllowed('tableDuplicate', {
source,
}) &&
base.sources?.[sourceIndex] &&
(source.is_meta || source.is_local)
isUIAllowed('tableDescriptionEdit', { roles: baseRole, source }) &&
!isUIAllowed('tableRename', { roles: baseRole, source })
"
:data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)"
:data-testid="`sidebar-table-description-${table.title}`"
class="nc-table-description"
@click="openTableDescriptionDialog(table)"
>
<div v-e="['c:table:duplicate']" class="flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }} {{ $t('objects.table').toLowerCase() }}
<div v-e="['c:table:update-description']" class="flex gap-2 items-center">
<!-- <GeneralIcon icon="ncAlignLeft" class="text-gray-700" /> -->
<GeneralIcon icon="ncAlignLeft" class="text-gray-700" />
{{ $t('labels.editDescription') }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem class="!text-gray-700" @click="onDuplicate">
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" />
{{
$t('general.duplicateEntity', {
entity: $t('title.defaultView').toLowerCase(),
})
}}
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableDelete', { roles: baseRole, source })"
:data-testid="`sidebar-table-delete-${table.title}`"
class="!text-red-500 !hover:bg-red-50 nc-table-delete"
@click="deleteTable"
<template
v-if="
!isSharedBase &&
(isUIAllowed('tableRename', { roles: baseRole, source }) ||
isUIAllowed('tableDelete', { roles: baseRole, source }))
"
>
<div v-e="['c:table:delete']" class="flex gap-2 items-center">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-e="['c:table:toggle-expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand text-gray-700 hover:text-gray-800"
:class="{
'!opacity-100 !visible': isOptionsOpen,
}"
@click.stop="onExpand"
>
<GeneralIcon
icon="chevronRight"
class="nc-sidebar-source-node-btns cursor-pointer transform transition-transform duration-200 !text-current text-[20px]"
:class="{ '!rotate-90': isExpanded }"
/>
</NcButton>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableRename', { roles: baseRole, source })"
:data-testid="`sidebar-table-rename-${table.title}`"
class="nc-table-rename"
@click="openRenameTableDialog(table, source.id)"
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="rename" class="text-gray-700" />
{{ $t('general.rename') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('tableDescriptionEdit', { roles: baseRole, source })"
:data-testid="`sidebar-table-description-${table.title}`"
class="nc-table-description"
@click="openTableDescriptionDialog(table)"
>
<div v-e="['c:table:update-description']" class="flex gap-2 items-center">
<!-- <GeneralIcon icon="ncAlignLeft" class="text-gray-700" /> -->
<GeneralIcon icon="ncAlignLeft" class="text-gray-700" />
{{ $t('labels.editDescription') }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="
isUIAllowed('tableDuplicate', {
source,
}) &&
base.sources?.[sourceIndex] &&
(source.is_meta || source.is_local)
"
:data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)"
>
<div v-e="['c:table:duplicate']" class="flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem class="!text-gray-700" @click="onDuplicate">
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" />
{{
$t('general.duplicateEntity', {
entity: $t('title.defaultView').toLowerCase(),
})
}}
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableDelete', { roles: baseRole, source })"
:data-testid="`sidebar-table-delete-${table.title}`"
class="!text-red-500 !hover:bg-red-50 nc-table-delete"
@click="deleteTable"
>
<div v-e="['c:table:delete']" class="flex gap-2 items-center">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-e="['c:table:toggle-expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand text-gray-700 hover:text-gray-800"
:class="{
'!opacity-100 !visible': isOptionsOpen,
}"
@click.stop="onExpand"
>
<GeneralIcon
icon="chevronRight"
class="nc-sidebar-source-node-btns cursor-pointer transform transition-transform duration-200 !text-current text-[20px]"
:class="{ '!rotate-90': isExpanded }"
/>
</NcButton>
</div>
</div>
</div>
<DlgTableDelete
@ -480,6 +530,8 @@ const source = computed(() => {
}
.nc-tree-item svg {
@apply text-primary text-opacity-60;
&:not(.nc-info-icon) {
@apply text-primary text-opacity-60;
}
}
</style>

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

@ -63,6 +63,8 @@ const isDefaultBase = computed(() => {
return _isDefaultBase(source)
})
const { openViewDescriptionDialog: _openViewDescriptionDialog } = inject(TreeViewInj)!
const input = ref<HTMLInputElement>()
const isDropdownOpen = ref(false)
@ -193,6 +195,12 @@ async function onRename() {
onStopEdit()
}
const openViewDescriptionDialog = (view: ViewType) => {
isDropdownOpen.value = false
_openViewDescriptionDialog(view)
}
/** Cancel renaming view */
function onCancel() {
if (!isEditing.value) return
@ -281,6 +289,15 @@ watch(isDropdownOpen, async () => {
</NcTooltip>
<template v-if="!isEditing && !isLocked">
<NcTooltip v-if="vModel.description?.length" placement="bottom">
<template #title>
{{ vModel.description }}
</template>
<NcButton type="text" class="!hover:bg-transparent" size="xsmall">
<GeneralIcon icon="info" class="!w-3.5 !h-3.5 nc-info-icon group-hover:opacity-100 text-gray-600 opacity-0" />
</NcButton>
</NcTooltip>
<NcDropdown v-model:visible="isDropdownOpen" overlay-class-name="!rounded-lg">
<NcButton
v-e="['c:view:option']"
@ -305,6 +322,7 @@ watch(isDropdownOpen, async () => {
@close-modal="isDropdownOpen = false"
@rename="onRenameMenuClick"
@delete="onDelete"
@description-update="openViewDescriptionDialog(vModel)"
/>
</template>
</NcDropdown>

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

@ -1,6 +1,6 @@
<script setup lang="ts">
import Draggable from 'vuedraggable'
import type { TableType } from 'nocodb-sdk'
import type { TableType, ViewType } from 'nocodb-sdk'
import ProjectWrapper from './ProjectWrapper.vue'
const { isUIAllowed } = useRoles()
@ -36,6 +36,46 @@ const setMenuContext = (type: 'base' | 'source' | 'table' | 'main' | 'layout', v
contextMenuTarget.value = value
}
function openViewDescriptionDialog(view: ViewType) {
if (!view || !view.id) return
$e('c:view:description')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgViewDescriptionUpdate'), {
'modelValue': isOpen,
'view': view,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
function openTableDescriptionDialog(table: TableType) {
if (!table || !table.id) return
$e('c:table:description')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgTableDescriptionUpdate'), {
'modelValue': isOpen,
'tableMeta': table,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
function openRenameTableDialog(table: TableType, _ = false) {
if (!table || !table.source_id) return
@ -159,6 +199,8 @@ provide(TreeViewInj, {
setMenuContext,
duplicateTable,
openRenameTableDialog,
openViewDescriptionDialog,
openTableDescriptionDialog,
contextMenuTarget,
})

108
packages/nc-gui/components/dlg/TableCreate.vue

@ -45,6 +45,13 @@ const { table, createTable, generateUniqueTitle, tables, base } = useTableNew({
const useForm = Form.useForm
const enableDescription = ref(false)
const removeDescription = () => {
table.description = ''
enableDescription.value = false
}
const validators = computed(() => {
return {
title: [
@ -111,6 +118,17 @@ const _createTable = async () => {
}
}
const toggleDescription = () => {
if (enableDescription.value) {
enableDescription.value = false
} else {
enableDescription.value = true
setTimeout(() => {
inputEl.value?.focus()
}, 100)
}
}
onMounted(() => {
generateUniqueTitle()
nextTick(() => {
@ -129,24 +147,27 @@ onMounted(() => {
@keydown.esc="dialogShow = false"
>
<template #header>
<div class="flex flex-row items-center gap-x-2 text-base text-gray-800">
<GeneralIcon icon="table" class="!text-gray-600 w-5 h-5" />
{{ $t('activity.createTable') }}
<div class="flex justify-between w-full items-center">
<div class="flex flex-row items-center gap-x-2 text-base font-semibold text-gray-800">
<GeneralIcon icon="table" class="!text-gray-600 w-5 h-5" />
{{ $t('activity.createTable') }}
</div>
<a href="https://docs.nocodb.com/tables/create-table" target="_blank" class="text-[13px]">
{{ $t('title.docs') }}
</a>
</div>
</template>
<div class="flex flex-col mt-1">
<a-form
layout="vertical"
:model="table"
name="create-new-table-form"
class="flex flex-col gap-5"
@keydown.enter="_createTable"
@keydown.esc="dialogShow = false"
>
<div>
<a-form-item
v-bind="validateInfos.title"
:class="{ '!mb-1': isSnowflake(props.sourceId), '!mb-0': !isSnowflake(props.sourceId) }"
>
<div class="flex flex-col gap-5">
<a-form-item v-bind="validateInfos.title">
<a-input
ref="inputEl"
v-model:value="table.title"
@ -156,6 +177,31 @@ onMounted(() => {
:placeholder="$t('msg.info.enterTableName')"
/>
</a-form-item>
<a-form-item
v-if="enableDescription"
v-bind="validateInfos.description"
:class="{ '!mb-1': isSnowflake(props.sourceId), '!mb-0': !isSnowflake(props.sourceId) }"
>
<div class="flex gap-3 text-gray-800 h-7 mb-1 items-center justify-between">
<span class="text-[13px]">
{{ $t('labels.description') }}
</span>
<NcButton type="text" class="!h-6 !w-5" size="xsmall" @click="removeDescription">
<GeneralIcon icon="delete" class="text-gray-700 w-3.5 h-3.5" />
</NcButton>
</div>
<a-textarea
ref="inputEl"
v-model:value="table.description"
class="nc-input-sm nc-input-text-area nc-input-shadow px-3 !text-gray-800 max-h-[150px] min-h-[100px]"
hide-details
data-testid="create-table-title-input"
:placeholder="$t('msg.info.enterTableDescription')"
/>
</a-form-item>
<template v-if="isSnowflake(props.sourceId)">
<a-checkbox v-model:checked="table.is_hybrid" class="!flex flex-row items-center"> Hybrid Table </a-checkbox>
</template>
@ -188,20 +234,32 @@ onMounted(() => {
</a-row>
</div>
</div>
<div class="flex flex-row justify-end gap-x-2">
<NcButton type="secondary" size="small" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton>
<NcButton
v-e="['a:table:create']"
type="primary"
size="small"
:disabled="validateInfos.title.validateStatus === 'error'"
:loading="creating"
@click="_createTable"
>
{{ $t('activity.createTable') }}
<template #loading> {{ $t('title.creatingTable') }} </template>
<div class="flex flex-row justify-between gap-x-2">
<NcButton v-if="!enableDescription" size="small" type="text" @click.stop="toggleDescription">
<div class="flex !text-gray-700 items-center gap-2">
<GeneralIcon icon="plus" class="h-4 w-4" />
<span class="first-letter:capitalize">
{{ $t('labels.addDescription').toLowerCase() }}
</span>
</div>
</NcButton>
<div v-else></div>
<div class="flex gap-2 items-center">
<NcButton type="secondary" size="small" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton>
<NcButton
v-e="['a:table:create']"
type="primary"
size="small"
:disabled="validateInfos.title.validateStatus === 'error'"
:loading="creating"
@click="_createTable"
>
{{ $t('activity.createTable') }}
<template #loading> {{ $t('title.creatingTable') }} </template>
</NcButton>
</div>
</div>
</a-form>
</div>
@ -209,6 +267,14 @@ onMounted(() => {
</template>
<style scoped lang="scss">
.ant-form-item {
@apply mb-0;
}
.nc-input-text-area {
padding-block: 8px !important;
}
.nc-table-advanced-options {
max-height: 0;
transition: 0.3s max-height;

182
packages/nc-gui/components/dlg/TableDescriptionUpdate.vue

@ -0,0 +1,182 @@
<script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import type { ComponentPublicInstance } from '@vue/runtime-core'
interface Props {
modelValue?: boolean
tableMeta: TableType
sourceId: string
}
const { tableMeta, ...props } = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'updated'])
const { $e, $api } = useNuxtApp()
const { setMeta } = useMetas()
const dialogShow = useVModel(props, 'modelValue', emit)
const { loadProjectTables } = useTablesStore()
const baseStore = useBase()
const { loadTables } = baseStore
const { addUndo, defineProjectScope } = useUndoRedo()
const inputEl = ref<HTMLTextAreaElement>()
const loading = ref(false)
const useForm = Form.useForm
const formState = reactive({
description: '',
})
const validators = computed(() => {
return {
description: [
{
validator: (_: any, _value: any) => {
return new Promise<void>((resolve, _reject) => {
resolve()
})
},
},
],
}
})
const { validateInfos } = useForm(formState, validators)
watchEffect(
() => {
if (tableMeta?.description) formState.description = `${tableMeta.description}`
nextTick(() => {
const input = inputEl.value?.$el as HTMLInputElement
if (input) {
input.setSelectionRange(0, formState.description.length)
input.focus()
}
})
},
{ flush: 'post' },
)
const updateDescription = async (undo = false) => {
if (!tableMeta) return
if (formState.description) {
formState.description = formState.description.trim()
}
loading.value = true
try {
await $api.dbTable.update(tableMeta.id as string, {
base_id: tableMeta.base_id,
description: formState.description,
})
dialogShow.value = false
await loadProjectTables(tableMeta.base_id!, true)
if (!undo) {
addUndo({
redo: {
fn: (t: string) => {
formState.description = t
updateDescription(true, true)
},
args: [formState.description],
},
undo: {
fn: (t: string) => {
formState.description = t
updateDescription(true, true)
},
args: [tableMeta.description],
},
scope: defineProjectScope({ model: tableMeta }),
})
}
await loadTables()
// update metas
const newMeta = await $api.dbTable.read(tableMeta.id as string)
await setMeta(newMeta)
$e('a:table:description:update')
dialogShow.value = false
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
loading.value = false
}
</script>
<template>
<NcModal v-model:visible="dialogShow" size="small" :show-separator="false">
<template #header>
<div class="flex flex-row items-center gap-x-2">
<GeneralIcon icon="table" class="w-6 h-6 text-gray-700" />
<span class="text-gray-900 font-bold">
{{ tableMeta?.title ?? tableMeta?.table_name }}
</span>
</div>
</template>
<div class="mt-1">
<a-form layout="vertical" :model="formState" name="create-new-table-form">
<a-form-item :label="$t('labels.description')" v-bind="validateInfos.description">
<a-textarea
ref="inputEl"
v-model:value="formState.description"
class="nc-input-sm !py-2 nc-text-area nc-input-shadow"
hide-details
size="small"
:placeholder="$t('msg.info.enterTableDescription')"
@keydown.enter.exact="() => updateDescription()"
/>
</a-form-item>
</a-form>
<div class="flex flex-row justify-end gap-x-2 mt-5">
<NcButton type="secondary" size="small" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton>
<NcButton
key="submit"
type="primary"
size="small"
:disabled="
validateInfos?.description?.validateStatus === 'error' || formState.description?.trim() === tableMeta?.description
"
:loading="loading"
@click="() => updateDescription()"
>
{{ $t('general.save') }}
</NcButton>
</div>
</div>
</NcModal>
</template>
<style scoped lang="scss">
.nc-text-area {
@apply !py-2 min-h-[120px] max-h-[200px];
}
:deep(.ant-form-item-label > label) {
@apply !text-md font-base !leading-[20px] text-gray-800 flex;
&.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before {
@apply content-[''] m-0;
}
}
</style>

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

@ -23,6 +23,7 @@ interface Props {
selectedViewId?: string
groupingFieldColumnId?: string
geoDataFieldColumnId?: string
description?: string
tableId: string
calendarRange?: Array<{
fk_from_column_id: string
@ -40,6 +41,7 @@ interface Emits {
interface Form {
title: string
type: ViewTypes
description?: string
copy_from_id: string | null
// for kanban view only
fk_grp_col_id: string | null
@ -103,6 +105,7 @@ const form = reactive<Form>({
fk_geo_data_col_id: null,
calendar_range: props.calendarRange || [],
fk_cover_image_col_id: null,
description: props.description || '',
})
const viewSelectFieldOptions = ref<SelectProps['options']>([])
@ -243,14 +246,35 @@ const addCalendarRange = async () => {
}
*/
const enableDescription = ref(false)
const removeDescription = () => {
form.description = ''
enableDescription.value = false
}
const toggleDescription = () => {
if (enableDescription.value) {
enableDescription.value = false
} else {
enableDescription.value = true
setTimeout(() => {
inputEl.value?.focus()
}, 100)
}
}
const isMetaLoading = ref(false)
onMounted(async () => {
if (form.copy_from_id) {
enableDescription.value = true
}
if ([ViewTypes.GALLERY, ViewTypes.KANBAN, ViewTypes.MAP, ViewTypes.CALENDAR].includes(props.type)) {
isMetaLoading.value = true
try {
meta.value = (await getMeta(tableId.value))!
if (props.type === ViewTypes.MAP) {
viewSelectFieldOptions.value = meta
.value!.columns!.filter((el) => el.uidt === UITypes.GeoData)
@ -708,28 +732,65 @@ onMounted(async () => {
</div>
</div>
<div class="flex flex-row w-full justify-end gap-x-2 mt-5">
<NcButton type="secondary" size="small" @click="vModel = false">
{{ $t('general.cancel') }}
</NcButton>
<a-form-item v-if="enableDescription">
<div class="flex gap-3 text-gray-800 h-7 mt-4 mb-1 items-center justify-between">
<span class="text-[13px]">
{{ $t('labels.description') }}
</span>
<NcButton
v-e="[form.copy_from_id ? 'a:view:duplicate' : 'a:view:create']"
:disabled="!isNecessaryColumnsPresent"
:loading="isViewCreating"
type="primary"
size="small"
@click="onSubmit"
>
{{ $t('labels.createView') }}
<template #loading> {{ $t('labels.creatingView') }}</template>
<NcButton type="text" class="!h-6 !w-5" size="xsmall" @click="removeDescription">
<GeneralIcon icon="delete" class="text-gray-700 w-3.5 h-3.5" />
</NcButton>
</div>
<a-textarea
ref="inputEl"
v-model:value="form.description"
class="nc-input-sm nc-input-text-area nc-input-shadow px-3 !text-gray-800 max-h-[150px] min-h-[100px]"
hide-details
data-testid="create-table-title-input"
:placeholder="$t('msg.info.enterViewDescription')"
/>
</a-form-item>
<div class="flex flex-row w-full justify-between gap-x-2 mt-5">
<NcButton v-if="!enableDescription" size="small" type="text" @click.stop="toggleDescription">
<div class="flex !text-gray-700 items-center gap-2">
<GeneralIcon icon="plus" class="h-4 w-4" />
<span class="first-letter:capitalize">
{{ $t('labels.addDescription') }}
</span>
</div>
</NcButton>
<div v-else></div>
<div class="flex gap-2 items-center">
<NcButton type="secondary" size="small" @click="vModel = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton
v-e="[form.copy_from_id ? 'a:view:duplicate' : 'a:view:create']"
:disabled="!isNecessaryColumnsPresent"
:loading="isViewCreating"
type="primary"
size="small"
@click="onSubmit"
>
{{ $t('labels.createView') }}
<template #loading> {{ $t('labels.creatingView') }}</template>
</NcButton>
</div>
</div>
</div>
</NcModal>
</template>
<style lang="scss" scoped>
.nc-input-text-area {
padding-block: 8px !important;
}
.ant-form-item-required {
@apply !text-gray-800 font-medium;
&:before {

170
packages/nc-gui/components/dlg/ViewDescriptionUpdate.vue

@ -0,0 +1,170 @@
<script setup lang="ts">
import type { ViewType } from 'nocodb-sdk'
import type { ComponentPublicInstance } from '@vue/runtime-core'
interface Props {
modelValue?: boolean
view: ViewType
sourceId?: string
}
const { view, ...props } = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'updated'])
const { $e, $api } = useNuxtApp()
const dialogShow = useVModel(props, 'modelValue', emit)
const { loadViews } = useViewsStore()
const { addUndo, defineProjectScope } = useUndoRedo()
const inputEl = ref<ComponentPublicInstance>()
const loading = ref(false)
const useForm = Form.useForm
const formState = reactive({
description: '',
})
const validators = computed(() => {
return {
description: [
{
validator: (_: any, _value: any) => {
return new Promise<void>((resolve, _reject) => {
resolve()
})
},
},
],
}
})
const { validateInfos } = useForm(formState, validators)
watchEffect(
() => {
if (view?.description) formState.description = `${view.description}`
nextTick(() => {
const input = inputEl.value?.$el as HTMLInputElement
if (input) {
input.setSelectionRange(0, formState.description.length)
input.focus()
}
})
},
{ flush: 'post' },
)
const updateDescription = async (undo = false) => {
if (!view) return
if (formState.description) {
formState.description = formState.description.trim()
}
loading.value = true
try {
await $api.dbView.update(view.id as string, {
description: formState.description,
})
dialogShow.value = false
if (!undo) {
addUndo({
redo: {
fn: (t: string) => {
formState.description = t
updateDescription(true, true)
},
args: [formState.description],
},
undo: {
fn: (t: string) => {
formState.description = t
updateDescription(true, true)
},
args: [view.description],
},
scope: defineProjectScope({ view }),
})
}
await loadViews({ tableId: view.fk_model_id, ignoreLoading: true, force: true })
$e('a:view:description:update')
dialogShow.value = false
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
loading.value = false
}
</script>
<template>
<NcModal v-model:visible="dialogShow" size="small" :show-separator="false">
<template #header>
<div class="flex flex-row items-center gap-x-2">
<GeneralViewIcon :meta="view" class="mt-0.5 !text-2xl" />
<span class="text-gray-900 font-semibold">
{{ view?.title }}
</span>
</div>
</template>
<div class="mt-1">
<a-form layout="vertical" :model="formState" name="create-new-table-form">
<a-form-item :label="$t('labels.description')" v-bind="validateInfos.description">
<a-textarea
ref="inputEl"
v-model:value="formState.description"
class="nc-input-sm !py-2 nc-text-area !text-gray-800 nc-input-shadow"
hide-details
size="small"
:placeholder="$t('msg.info.enterTableDescription')"
@keydown.enter.exact="() => updateDescription()"
/>
</a-form-item>
</a-form>
<div class="flex flex-row justify-end gap-x-2 mt-5">
<NcButton type="secondary" size="small" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton>
<NcButton
key="submit"
type="primary"
size="small"
:disabled="
validateInfos?.description?.validateStatus === 'error' || formState.description?.trim() === view?.description
"
:loading="loading"
@click="() => updateDescription()"
>
{{ $t('general.save') }}
</NcButton>
</div>
</div>
</NcModal>
</template>
<style scoped lang="scss">
.nc-text-area {
@apply !py-2 min-h-[120px] max-h-[200px];
}
:deep(.ant-form-item-label > label) {
@apply !leading-[20px] font-base !text-md text-gray-800 flex;
&.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before {
@apply content-[''] m-0;
}
}
</style>

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

@ -97,7 +97,7 @@ useEventListener(NcButton, 'mousedown', () => {
'justify-center': props.centered,
'justify-start': !props.centered,
}"
class="flex flex-row gap-x-2.5 w-full"
class="flex flex-row gap-x-2.5 nc-btn-inner w-full"
>
<template v-if="iconPosition === 'left'">
<GeneralLoader

73
packages/nc-gui/components/nc/Tooltip.vue

@ -35,6 +35,8 @@ const color = computed(() => (props.color ? props.color : 'dark'))
const el = ref()
const element = ref()
const showTooltip = controlledRef(false, {
onBeforeChange: (shouldShow) => {
if (shouldShow && disabled.value) return false
@ -43,6 +45,8 @@ const showTooltip = controlledRef(false, {
const isHovering = useElementHover(() => el.value)
const isOverlayHovering = useElementHover(() => element.value)
const attrs = useAttrs()
const isKeyPressed = ref(false)
@ -74,38 +78,51 @@ onKeyStroke(
{ eventName: 'keyup' },
)
watch([isHovering, () => modifierKey.value, () => disabled.value], ([hovering, key, isDisabled]) => {
if (showOnTruncateOnly?.value) {
const targetElement = el?.value
const isElementTruncated = targetElement && targetElement.scrollWidth > targetElement.clientWidth
if (!isElementTruncated) {
watchDebounced(
[isOverlayHovering, isHovering, () => modifierKey.value, () => disabled.value],
([overlayHovering, hovering, key, isDisabled]) => {
if (showOnTruncateOnly?.value) {
const targetElement = el?.value
const isElementTruncated = targetElement && targetElement.scrollWidth > targetElement.clientWidth
if (!isElementTruncated) {
if (overlayHovering) {
showTooltip.value = true
return
}
showTooltip.value = false
return
}
}
if (overlayHovering) {
showTooltip.value = true
return
}
if ((!hovering || isDisabled) && !props.mouseLeaveDelay) {
showTooltip.value = false
return
}
}
if ((!hovering || isDisabled) && !props.mouseLeaveDelay) {
showTooltip.value = false
return
}
// Show tooltip on mouseover if no modifier key is provided
if (hovering && !key) {
showTooltip.value = true
return
}
// Show tooltip on mouseover if no modifier key is provided
if (hovering && !key) {
showTooltip.value = true
return
}
// While hovering if the modifier key was changed and the key is not pressed, hide tooltip
if (hovering && key && !isKeyPressed.value) {
showTooltip.value = false
return
}
// While hovering if the modifier key was changed and the key is not pressed, hide tooltip
if (hovering && key && !isKeyPressed.value) {
showTooltip.value = false
return
}
// When mouse leaves the element, then re-enters the element while key stays pressed, show the tooltip
if (!showTooltip.value && hovering && key && isKeyPressed.value) {
showTooltip.value = true
}
})
// When mouse leaves the element, then re-enters the element while key stays pressed, show the tooltip
if (!showTooltip.value && hovering && key && isKeyPressed.value) {
showTooltip.value = true
}
},
{
debounce: 100,
},
)
const divStyles = computed(() => ({
style: attrs.style as CSSProperties,
@ -131,7 +148,9 @@ const onClick = () => {
:mouse-leave-delay="mouseLeaveDelay"
>
<template #title>
<slot name="title" />
<div ref="element">
<slot name="title" />
</div>
</template>
<component

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

@ -48,13 +48,13 @@ watch(
<NcButton
size="small"
type="text"
class="!text-gray-500 !hover:text-gray-700"
class="!text-gray-700"
data-testid="nc-show-default-value-btn"
@click.stop="isVisibleDefaultValueInput = true"
>
<div class="flex items-center gap-2">
<span>{{ $t('general.set') }} {{ $t('placeholder.defaultValue').toLowerCase() }}</span>
<GeneralIcon icon="plus" class="flex-none h-4 w-4" />
<span>{{ $t('general.set') }} {{ $t('placeholder.defaultValue').toLowerCase() }}</span>
</div>
</NcButton>
</div>

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

@ -25,6 +25,7 @@ const props = defineProps<{
hideType?: boolean
hideAdditionalOptions?: boolean
fromTableExplorer?: boolean
editDescription?: boolean
readonly?: boolean
}>()
@ -43,6 +44,8 @@ const {
column,
} = useColumnCreateStoreOrThrow()
const editDescription = toRef(props, 'editDescription')
const { getMeta } = useMetas()
const { t } = useI18n()
@ -237,7 +240,19 @@ watchEffect(() => {
advancedOptions.value = false
})
const enableDescription = ref(false)
const descInputEl = ref()
const removeDescription = () => {
formState.value.description = ''
enableDescription.value = false
}
onMounted(() => {
if (column.value?.description?.length || editDescription.value) {
enableDescription.value = true
}
if (!isEdit.value) {
generateNewColumnMeta(true)
} else {
@ -286,11 +301,15 @@ onMounted(() => {
}
}
if (isForm.value && !props.fromTableExplorer) {
if (isForm.value && !props.fromTableExplorer && !enableDescription.value) {
setTimeout(() => {
antInput.value?.focus()
antInput.value?.select()
}, 100)
} else if (enableDescription.value) {
setTimeout(() => {
descInputEl.value?.focus()
}, 100)
}
})
})
@ -346,6 +365,17 @@ const filterOption = (input: string, option: { value: UITypes }) => {
)
}
const triggerDescriptionEnable = () => {
if (enableDescription.value) {
enableDescription.value = false
} else {
enableDescription.value = true
setTimeout(() => {
descInputEl.value?.focus()
}, 100)
}
}
const isFullUpdateAllowed = computed(() => {
if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(formState.value?.uidt) && !isVirtualCol(formState.value)) {
return false
@ -585,41 +615,89 @@ const isFullUpdateAllowed = computed(() => {
</Transition>
</template>
<a-form-item v-if="enableDescription">
<div class="flex gap-3 text-gray-800 h-7 mb-1 items-center justify-between">
<span class="text-[13px]">
{{ $t('labels.description') }}
</span>
<NcButton type="text" class="!h-6 !w-5" size="xsmall" @click="removeDescription">
<GeneralIcon icon="delete" class="text-gray-700 w-3.5 h-3.5" />
</NcButton>
</div>
<a-textarea
ref="descInputEl"
v-model:value="formState.description"
:class="{
'!min-h-[200px]': fromTableExplorer,
'h-[150px] !min-h-[100px]': !fromTableExplorer,
}"
class="nc-input-sm nc-input-text-area nc-input-shadow !text-gray-800 px-3 !max-h-[300px]"
hide-details
data-testid="create-field-description-input"
:placeholder="$t('msg.info.enterFieldDescription')"
/>
</a-form-item>
<template v-if="props.fromTableExplorer">
<a-form-item></a-form-item>
<a-form-item>
<NcButton v-if="!enableDescription" size="small" type="text" @click.stop="triggerDescriptionEnable">
<div class="flex !text-gray-700 items-center gap-2">
<GeneralIcon icon="plus" class="h-4 w-4" />
<span class="first-letter:capitalize">
{{ $t('labels.addDescription').toLowerCase() }}
</span>
</div>
</NcButton>
</a-form-item>
</template>
<template v-else>
<a-form-item>
<div
class="flex gap-x-2 justify-end"
:class="{
'justify-end': !props.embedMode,
}"
>
<!-- Cancel -->
<NcButton size="small" html-type="button" type="secondary" @click="emit('cancel')">
{{ $t('general.cancel') }}
</NcButton>
<!-- Save -->
<NcButton
html-type="submit"
type="primary"
:loading="saving"
:disabled="!formState.uidt || disableSubmitBtn"
size="small"
:label="submitBtnLabel.label"
:loading-label="submitBtnLabel.loadingLabel"
data-testid="nc-field-modal-submit-btn"
@click.prevent="onSubmit"
<div class="flex items-center justify-between gap-2">
<NcButton v-if="!enableDescription" size="small" type="text" @click.stop="triggerDescriptionEnable">
<div class="flex !text-gray-700 items-center gap-2">
<GeneralIcon icon="plus" class="h-4 w-4" />
<span class="first-letter:capitalize">
{{ $t('labels.addDescription').toLowerCase() }}
</span>
</div>
</NcButton>
<div v-else></div>
<a-form-item>
<div
class="flex gap-x-2 justify-end"
:class="{
'justify-end': !props.embedMode,
}"
>
{{ submitBtnLabel.label }}
<template #loading>
{{ submitBtnLabel.loadingLabel }}
</template>
</NcButton>
</div>
</a-form-item>
<!-- Cancel -->
<NcButton size="small" html-type="button" type="secondary" @click="emit('cancel')">
{{ $t('general.cancel') }}
</NcButton>
<!-- Save -->
<NcButton
html-type="submit"
type="primary"
:loading="saving"
:disabled="!formState.uidt || disableSubmitBtn"
size="small"
:label="submitBtnLabel.label"
:loading-label="submitBtnLabel.loadingLabel"
data-testid="nc-field-modal-submit-btn"
@click.prevent="onSubmit"
>
{{ submitBtnLabel.label }}
<template #loading>
{{ submitBtnLabel.loadingLabel }}
</template>
</NcButton>
</div>
</a-form-item>
</div>
</template>
</template>
</a-form>
@ -635,6 +713,11 @@ const isFullUpdateAllowed = computed(() => {
</style>
<style lang="scss" scoped>
.nc-input-text-area {
@apply !text-gray-800;
padding-block: 8px !important;
}
.nc-fields-input {
&::placeholder {
@apply font-normal;

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

@ -7,6 +7,7 @@ interface Props {
columnPosition?: Pick<ColumnReqType, 'column_order'>
preload?: Partial<ColumnType>
tableExplorerColumns?: ColumnType[]
editDescription?: boolean
fromTableExplorer?: boolean
isColumnValid?: (value: Partial<ColumnType>) => boolean
}
@ -17,7 +18,7 @@ const emit = defineEmits(['submit', 'cancel', 'mounted'])
const meta = inject(MetaInj, ref())
const { column, preload, tableExplorerColumns, fromTableExplorer, isColumnValid } = toRefs(props)
const { column, preload, tableExplorerColumns, fromTableExplorer, isColumnValid, editDescription } = toRefs(props)
useProvideColumnCreateStore(meta, column, tableExplorerColumns, fromTableExplorer, isColumnValid)
@ -36,6 +37,7 @@ defineExpose({
<SmartsheetColumnEditOrAdd
:preload="preload"
:column-position="props.columnPosition"
:edit-description="editDescription"
:from-table-explorer="props.fromTableExplorer || false"
@submit="emit('submit')"
@cancel="emit('cancel')"

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

@ -28,13 +28,13 @@ const cdfValue = computed({
<NcButton
size="small"
type="text"
class="!text-gray-500 !hover:text-gray-700"
class="text-gray-700"
data-testid="nc-show-default-value-btn"
@click.stop="isVisibleDefaultValueInput = true"
>
<div class="flex items-center gap-2">
<span>{{ $t('general.set') }} {{ $t('placeholder.defaultValue').toLowerCase() }}</span>
<GeneralIcon icon="plus" class="flex-none h-4 w-4" />
<span>{{ $t('general.set') }} {{ $t('placeholder.defaultValue').toLowerCase() }}</span>
</div>
</NcButton>
</div>

24
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -52,6 +52,14 @@ const addField = async (payload: any) => {
editColumnDropdown.value = true
}
const enableDescription = ref(false)
watch(editColumnDropdown, (val) => {
if (!val) {
enableDescription.value = false
}
})
const closeAddColumnDropdown = () => {
columnOrder.value = null
editColumnDropdown.value = false
@ -67,10 +75,13 @@ const isColumnEditAllowed = computed(() => {
return true
})
const openHeaderMenu = (e?: MouseEvent) => {
const openHeaderMenu = (e?: MouseEvent, description = false) => {
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if (!isForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value && isColumnEditAllowed.value) {
if (!isForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value && (isColumnEditAllowed.value || description)) {
if (description) {
enableDescription.value = true
}
editColumnDropdown.value = true
}
}
@ -113,7 +124,7 @@ const onClick = (e: Event) => {
isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit') && !isExpandedBulkUpdateForm,
'bg-gray-100': isExpandedForm && !isExpandedBulkUpdateForm ? editColumnDropdown || isDropDownOpen : false,
}"
@dblclick="openHeaderMenu"
@dblclick="openHeaderMenu($event, false)"
@click.right="openDropDown"
@click="onClick"
>
@ -181,6 +192,12 @@ const onClick = (e: Event) => {
}"
/>
</div>
<NcTooltip v-if="column.description?.length && hideMenu">
<template #title>
{{ column.description }}
</template>
<GeneralIcon icon="info" class="group-hover:opacity-100 !w-3.5 !h-3.5 !text-gray-500" />
</NcTooltip>
<template v-if="!hideMenu">
<div v-if="!isExpandedForm" class="flex-1" />
@ -210,6 +227,7 @@ const onClick = (e: Event) => {
:column="columnOrder ? null : column"
:column-position="columnOrder"
class="w-full"
:edit-description="enableDescription"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop

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

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { type ColumnReqType, partialUpdateAllowedTypes, readonlyMetaAllowedTypes } from 'nocodb-sdk'
import { type ColumnReqType, type ColumnType, partialUpdateAllowedTypes, readonlyMetaAllowedTypes } from 'nocodb-sdk'
import { PlanLimitTypes, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import { SmartsheetStoreEvents, isColumnInvalid } from '#imports'
@ -307,9 +307,9 @@ const handleDelete = () => {
showDeleteColumnModal.value = true
}
const onEditPress = () => {
const onEditPress = (event?: MouseEvent, enableDescription = false) => {
isOpen.value = false
emit('edit')
emit('edit', event, enableDescription)
}
const onInsertBefore = () => {
@ -401,19 +401,40 @@ const changeTitleField = () => {
isOpen.value = false
changeTitleFieldMenu.value = true
}
const openDropdown = () => {
if (isLocked) return
isOpen.value = !isOpen.value
}
const isFieldIdCopied = ref(false)
const { copy } = useClipboard()
const onClickCopyFieldUrl = async (field: ColumnType) => {
await copy(field.id!)
isFieldIdCopied.value = true
}
</script>
<template>
<a-dropdown
v-if="!isLocked"
v-model:visible="isOpen"
:disabled="isLocked"
:trigger="['click']"
:placement="isExpandedForm ? 'bottomLeft' : 'bottomRight'"
overlay-class-name="nc-dropdown-column-operations !border-1 rounded-lg !shadow-xl "
@click.stop="isOpen = !isOpen"
@click.stop="openDropdown"
>
<div class="flex items-center gap-2" @dblclick.stop>
<div class="flex gap-0.5 items-center" @dblclick.stop>
<div v-if="isExpandedForm" class="h-[1px]">&nbsp;</div>
<NcTooltip v-if="column.description?.length">
<template #title>
{{ column.description }}
</template>
<GeneralIcon icon="info" class="group-hover:opacity-100 !w-3.5 !h-3.5 !text-gray-500" />
</NcTooltip>
<NcTooltip class="flex items-center">
<GeneralIcon
@ -427,7 +448,7 @@ const changeTitleField = () => {
</template>
</NcTooltip>
<GeneralIcon
v-if="!isExpandedForm"
v-if="!isExpandedForm && !isLocked"
icon="arrowDown"
class="text-grey h-full text-grey nc-ui-dt-dropdown cursor-pointer outline-0 mr-2"
/>
@ -439,12 +460,39 @@ const changeTitleField = () => {
'min-w-[256px]': isExpandedForm,
}"
>
<NcMenuItem class="!h-9.5 nc-copy-field" @click="onClickCopyFieldUrl(column)">
<NcTooltip
:attrs="{
class: 'w-full',
}"
placement="top"
>
<template #title>{{ $t('msg.clickToCopyFieldId') }}</template>
<div
class="flex flex-row justify-between items-center w-full group hover:bg-gray-100 cursor-pointer"
data-testid="nc-field-item-action-copy-id"
>
<div class="flex flex-row text-gray-500 text-xs items-baseline gap-x-1 font-bold text-xs">
<div class="whitespace-nowrap">{{ $t('labels.idColon') }}</div>
<div class="flex flex-row truncate">
{{ column.id }}
</div>
</div>
<NcButton size="xsmall" type="text" class="!group-hover:bg-gray-100">
<GeneralIcon v-if="isFieldIdCopied" icon="check" />
<GeneralIcon v-else icon="copy" />
</NcButton>
</div>
</NcTooltip>
</NcMenuItem>
<a-divider class="!my-0" />
<GeneralSourceRestrictionTooltip message="Field properties cannot be edited." :enabled="!isColumnEditAllowed">
<NcMenuItem
v-if="isUIAllowed('fieldAlter')"
:disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed || linksAssociated.length"
:title="linksAssociated.length ? 'Field is associated with a link column' : undefined"
@click="onEditPress"
@click="onEditPress($event, false)"
>
<div class="nc-column-edit nc-header-menu-item">
<component :is="iconMap.ncEdit" class="text-gray-500" />
@ -453,6 +501,31 @@ const changeTitleField = () => {
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
<template v-if="!isExpandedForm">
<GeneralSourceRestrictionTooltip message="Field cannot be duplicated." :enabled="!isDuplicateAllowed && isMetaReadOnly">
<NcMenuItem v-if="!column?.pk" :disabled="!isDuplicateAllowed" @click="openDuplicateDlg">
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-500" />
<!-- Duplicate -->
{{ t('general.duplicate') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
<GeneralSourceRestrictionTooltip message="Field cannot be duplicated." :enabled="!isDuplicateAllowed">
<NcMenuItem
v-if="isUIAllowed('duplicateColumn') && isExpandedForm && !column?.pk"
:disabled="!isDuplicateAllowed"
@click="openDuplicateDlg"
>
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-500" />
<!-- Duplicate -->
{{ t('general.duplicate') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
</template>
<NcMenuItem
v-if="isUIAllowed('fieldAlter') && !!column?.pv"
title="Select a new field as display value"
@ -463,25 +536,19 @@ const changeTitleField = () => {
{{ $t('labels.changeDisplayValueField') }}
</div>
</NcMenuItem>
<NcMenuItem v-if="isUIAllowed('fieldAlter')" title="Add field description" @click="onEditPress($event, true)">
<div class="nc-column-edit-description nc-header-menu-item">
<GeneralIcon icon="ncAlignLeft" class="text-gray-500 !w-4.25 !h-4.25" />
{{ $t('labels.editDescription') }}
</div>
</NcMenuItem>
<NcMenuItem v-if="[UITypes.LinkToAnotherRecord, UITypes.Links].includes(column.uidt)" @click="openLookupMenuDialog">
<div v-e="['a:field:lookup:create']" class="nc-column-lookup-create nc-header-menu-item">
<component :is="iconMap.cellLookup" class="text-gray-500 !w-4.5 !h-4.5" />
{{ t('general.addLookupField') }}
</div>
</NcMenuItem>
<GeneralSourceRestrictionTooltip message="Field cannot be duplicated." :enabled="!isDuplicateAllowed">
<NcMenuItem
v-if="isUIAllowed('duplicateColumn') && isExpandedForm && !column?.pk"
:disabled="!isDuplicateAllowed"
@click="openDuplicateDlg"
>
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-500" />
<!-- Duplicate -->
{{ t('general.duplicate') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
<a-divider v-if="isUIAllowed('fieldAlter') && !column?.pv" class="!my-0" />
<NcMenuItem v-if="!column?.pv" @click="hideOrShowField">
<div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item">
@ -590,15 +657,6 @@ const changeTitleField = () => {
</NcTooltip>
<a-divider class="!my-0" />
<GeneralSourceRestrictionTooltip message="Field cannot be duplicated." :enabled="!isDuplicateAllowed && isMetaReadOnly">
<NcMenuItem v-if="!column?.pk" :disabled="!isDuplicateAllowed" @click="openDuplicateDlg">
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-500" />
<!-- Duplicate -->
{{ t('general.duplicate') }} {{ $t('objects.field').toLowerCase() }}
</div>
</NcMenuItem>
</GeneralSourceRestrictionTooltip>
<NcMenuItem @click="onInsertAfter">
<div v-e="['a:field:insert:after']" class="nc-column-insert-after nc-header-menu-item">
<component :is="iconMap.colInsertAfter" class="text-gray-500 !w-4.5 !h-4.5" />
@ -649,7 +707,10 @@ const changeTitleField = () => {
<LazySmartsheetHeaderUpdateDisplayValue v-if="changeTitleFieldMenu" v-model:value="changeTitleFieldMenu" :column="column" />
</template>
<style scoped>
<style scoped lang="scss">
:deep(.nc-menu-item-inner) {
@apply !w-full;
}
.nc-header-menu-item {
@apply text-dropdown flex items-center gap-2;
}

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

@ -31,6 +31,8 @@ const editColumnDropdown = ref(false)
const isDropDownOpen = ref(false)
const enableDescription = ref(false)
const isLocked = inject(IsLockedInj, ref(false))
provide(ColumnInj, column)
@ -122,7 +124,13 @@ const closeAddColumnDropdown = () => {
editColumnDropdown.value = false
}
const openHeaderMenu = (e?: MouseEvent) => {
watch(editColumnDropdown, (val) => {
if (!val) {
enableDescription.value = false
}
})
const openHeaderMenu = (e?: MouseEvent, description = false) => {
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if (
@ -131,6 +139,9 @@ const openHeaderMenu = (e?: MouseEvent) => {
!isMobileMode.value &&
(!isMetaReadOnly.value || readonlyMetaAllowedTypes.includes(column.value.uidt))
) {
if (description) {
enableDescription.value = true
}
editColumnDropdown.value = true
}
}
@ -150,6 +161,7 @@ const onVisibleChange = () => {
editColumnDropdown.value = true
if (!editOrAddProviderRef.value?.isWebHookModalOpen()) {
editColumnDropdown.value = false
enableDescription.value = false
}
}
@ -231,7 +243,7 @@ const onClick = (e: Event) => {
:is-hidden-col="isHiddenCol"
:virtual="true"
@add-column="addField"
@edit="editColumnDropdown = true"
@edit="openHeaderMenu"
/>
</template>
@ -253,6 +265,7 @@ const onClick = (e: Event) => {
:column="columnOrder ? null : column"
:column-position="columnOrder"
class="w-full"
:edit-description="enableDescription"
@submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown"
@click.stop

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

@ -7,7 +7,7 @@ const { isSharedBase, base } = storeToRefs(useBase())
const { t } = useI18n()
const { $api } = useNuxtApp()
const { $api, $e } = useNuxtApp()
const { refreshCommandPalette } = useCommandPalette()
@ -28,6 +28,28 @@ const viewRenameTitle = ref('')
const error = ref<string | undefined>()
const updateDescription = async () => {
if (!activeView.value || !activeView.value.id) return
$e('c:view:description')
const isOpen = ref(true)
isDropdownOpen.value = false
const { close } = useDialog(resolveComponent('DlgViewDescriptionUpdate'), {
'modelValue': isOpen,
'view': activeView.value,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
const onRenameMenuClick = () => {
isRenaming.value = true
isDropdownOpen.value = false
@ -190,6 +212,7 @@ function openDeleteDialog() {
@close-modal="isDropdownOpen = false"
@rename="onRenameMenuClick"
@delete="openDeleteDialog"
@description-update="updateDescription"
/>
</template>
</NcDropdown>

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

@ -14,7 +14,7 @@ const props = withDefaults(
},
)
const emits = defineEmits(['rename', 'closeModal', 'delete'])
const emits = defineEmits(['rename', 'closeModal', 'delete', 'descriptionUpdate'])
const { isUIAllowed, isDataReadOnly } = useRoles()
@ -47,6 +47,10 @@ const onRenameMenuClick = () => {
emits('rename')
}
const onDescriptionUpdateClick = () => {
emits('descriptionUpdate')
}
const quickImportDialogTypes: QuickImportDialogType[] = ['csv', 'excel']
const quickImportDialogs: Record<(typeof quickImportDialogTypes)[number], Ref<boolean>> = quickImportDialogTypes.reduce(
@ -102,6 +106,7 @@ function onDuplicate() {
'selectedViewId': view.value!.id,
'groupingFieldColumnId': view.value!.view!.fk_grp_col_id,
'views': views,
'description': view.value!.description,
'calendarRange': view.value!.view!.calendar_range,
'coverImageColumnId': view.value!.view!.fk_cover_image_col_id,
'onUpdate:modelValue': closeDialog,
@ -195,6 +200,12 @@ const onDelete = async () => {
}}
</NcMenuItem>
</NcTooltip>
<NcMenuItem v-if="lockType !== LockType.Locked" @click="onDescriptionUpdateClick">
<GeneralIcon icon="ncAlignLeft" />
{{ $t('general.edit') }}
{{ $t('labels.description') }}
</NcMenuItem>
</template>
<NcMenuItem @click="onDuplicate">
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" />
@ -205,7 +216,6 @@ const onDelete = async () => {
}}
</NcMenuItem>
</template>
<template v-if="view.type !== ViewTypes.FORM">
<NcDivider />
<template v-if="isUIAllowed('csvTableImport') && !isPublicView && !isDataReadOnly">

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

@ -85,6 +85,7 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const defaultType = isMetaReadOnly.value ? UITypes.Formula : UITypes.SingleLineText
const formState = ref<Record<string, any>>({
title: '',
description: '',
uidt: fromTableExplorer?.value ? defaultType : null,
custom: {},
...clone(column.value || {}),

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

@ -4,9 +4,10 @@ import type { SidebarTableNode } from '~/lib/types'
import { generateUniqueTitle as generateTitle } from '#imports'
export function useTableNew(param: { onTableCreate?: (tableMeta: TableType) => void; baseId: string; sourceId?: string }) {
const table = reactive<{ title: string; table_name: string; columns: string[]; is_hybrid: boolean }>({
const table = reactive<{ title: string; table_name: string; description?: string; columns: string[]; is_hybrid: boolean }>({
title: '',
table_name: '',
description: '',
columns: SYSTEM_COLUMNS,
is_hybrid: true,
})

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

@ -25,6 +25,8 @@ const [useProvideViewAggregate, useViewAggregate] = useInjectionState(
const { nestedFilters } = useSmartsheetStoreOrThrow()
const { isUIAllowed } = useRoles()
const { fetchAggregatedData } = useSharedView()
const aggregations = ref({}) as Ref<Record<string, any>>

2
packages/nc-gui/context/index.ts

@ -64,6 +64,8 @@ export const TreeViewInj: InjectionKey<{
setMenuContext: (type: 'base' | 'base' | 'table' | 'main' | 'layout', value?: any) => void
duplicateTable: (table: TableType) => void
openRenameTableDialog: (table: TableType, rightClick: boolean) => void
openViewDescriptionDialog: (view: ViewType) => void
openTableDescriptionDialog: (table: TableType) => void
contextMenuTarget: { type?: 'base' | 'base' | 'table' | 'main' | 'layout'; value?: any }
}> = Symbol('tree-view-functions-injection')
export const CalendarViewTypeInj: InjectionKey<Ref<'week' | 'month' | 'day' | 'year'>> = Symbol('calendar-view-type-injection')

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

@ -616,6 +616,9 @@
"categories": "Categories"
},
"labels": {
"fieldID": "Field ID",
"addDescription": "Add description",
"editDescription": "Edit description",
"urlFormula": "URL Formula",
"selectIcon": "none",
"selectAWebhook": "--select a webhook--",
@ -1596,6 +1599,9 @@
"tablesMetadataInSync": "Tables metadata is in Sync",
"addMultipleUsers": "You can add multiple comma(,) separated emails",
"enterTableName": "Enter table name",
"enterTableDescription": "Enter table description...",
"enterFieldDescription": "Enter field description...",
"enterViewDescription": "Enter view description...",
"enterLayoutName": "Enter Layout name",
"enterDashboardName": "Enter Dashboard name",
"defaultColumns": "Default fields",

17
packages/nc-gui/layouts/shared-view.vue

@ -59,12 +59,7 @@ export default {
target="_blank"
rel="noopener noreferrer"
>
<NcTooltip placement="bottom" class="flex">
<template #title>
{{ appInfo.version }}
</template>
<img width="96" alt="NocoDB" src="~/assets/img/brand/nocodb.png" class="flex-none min-w-[96px]" />
</NcTooltip>
<img width="96" alt="NocoDB" src="~/assets/img/brand/nocodb.png" class="flex-none min-w-[96px]" />
</a>
<div class="flex items-center gap-2 text-gray-900 text-sm truncate">
@ -80,6 +75,16 @@ export default {
<span class="truncate">
{{ sharedView?.title }}
</span>
<NcTooltip v-if="sharedView?.description?.length" placement="bottom">
<template #title>
{{ sharedView?.description }}
</template>
<NcButton type="text" class="!hover:bg-transparent" size="xsmall">
<GeneralIcon icon="info" class="!w-3.5 !h-3.5 nc-info-icon text-gray-600" />
</NcButton>
</NcTooltip>
</div>
</div>
</div>

1
packages/nc-gui/lib/acl.ts

@ -64,6 +64,7 @@ const rolePermissions = {
tableCreate: true,
tableRename: true,
tableDelete: true,
tableDescriptionEdit: true,
tableDuplicate: true,
tableSort: true,
layoutRename: true,

22
packages/nc-gui/utils/iconUtils.ts

@ -546,6 +546,12 @@ import NcLangRuby from '~icons/nc-icons/lang-ruby.svg'
import NcLangJava from '~icons/nc-icons/lang-java.svg'
import NcLangC from '~icons/nc-icons/lang-c.svg'
import NcGridViewIcon from '~icons/nc-icons/grid'
import NcFormViewIcon from '~icons/nc-icons/form'
import NcGalleryViewIcon from '~icons/nc-icons/gallery'
import NcKanbanViewIcon from '~icons/nc-icons/kanban'
import NcCalendarViewIcon from '~icons/nc-icons/calendar'
// keep it for reference
// todo: remove it after all icons are migrated
/* export const iconMapOld = {
@ -694,6 +700,12 @@ import NcLangC from '~icons/nc-icons/lang-c.svg'
} as const */
export const iconMap = {
calendar: NcCalendarViewIcon,
grid: NcGridViewIcon,
form: NcFormViewIcon,
gallery: NcGalleryViewIcon,
kanban: NcKanbanViewIcon,
strike: NcStrike,
atSign: NcAtSign,
slash: NcSlash,
@ -753,7 +765,7 @@ export const iconMap = {
workspaceDefault: MsGroup,
project: Project,
search: NcSearch,
calendar: Calendar,
// calendar: Calendar,
checkCircle: NcCheckCircle,
checkFill: NcCheckFill,
externalLink: NcExternalLink,
@ -902,11 +914,11 @@ export const iconMap = {
xml: h('span', { class: 'material-symbols' }, 'code'),
airtable: LogosAirtable,
excelColored: VscodeIconsExcelColored,
grid: h('span', { class: 'material-symbols' }, 'grid_view'),
gallery: h('span', { class: 'material-symbols' }, 'image'),
form: h('span', { class: 'material-symbols' }, 'article'),
// grid: h('span', { class: 'material-symbols' }, 'grid_view'),
// gallery: h('span', { class: 'material-symbols' }, 'image'),
// form: h('span', { class: 'material-symbols' }, 'article'),
map: h('span', { class: 'material-symbols' }, 'map'),
kanban: h('span', { class: 'material-symbols' }, 'view_kanban'),
// kanban: h('span', { class: 'material-symbols' }, 'view_kanban'),
view: h('span', { class: 'material-symbols' }, 'visibility'),
// rowHeight: h('span', { class: 'material-symbols' }, 'height'),
rowHeight: h(PhSplitVerticalThin, { style: { fontSize: '14px' } }),

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -46,6 +46,7 @@ import * as nc_056_integration from '~/meta/migrations/v2/nc_056_integration';
import * as nc_057_file_references from '~/meta/migrations/v2/nc_057_file_references';
import * as nc_058_button_colum from '~/meta/migrations/v2/nc_058_button_colum';
import * as nc_059_invited_by from '~/meta/migrations/v2/nc_059_invited_by';
import * as nc_060_descriptions from '~/meta/migrations/v2/nc_060_descriptions';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -103,6 +104,7 @@ export default class XcMigrationSourcev2 {
'nc_057_file_references',
'nc_058_button_colum',
'nc_059_invited_by',
'nc_060_descriptions',
]);
}
@ -208,6 +210,8 @@ export default class XcMigrationSourcev2 {
return nc_058_button_colum;
case 'nc_059_invited_by':
return nc_059_invited_by;
case 'nc_060_descriptions':
return nc_060_descriptions;
}
}
}

28
packages/nocodb/src/meta/migrations/v2/nc_060_descriptions.ts

@ -0,0 +1,28 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const alterColumnToText = async (knex: Knex, table: string) => {
await knex.schema.alterTable(table, (t) => {
t.text('description').alter();
});
};
const alterColumnToString = async (knex: Knex, table: string) => {
await knex.schema.alterTable(table, (t) => {
t.string('description', 255).alter();
});
};
const up = async (knex: Knex) => {
await alterColumnToText(knex, MetaTable.COLUMNS);
await alterColumnToText(knex, MetaTable.MODELS);
await alterColumnToText(knex, MetaTable.VIEWS);
};
const down = async (knex: Knex) => {
await alterColumnToString(knex, MetaTable.COLUMNS);
await alterColumnToString(knex, MetaTable.MODELS);
await alterColumnToString(knex, MetaTable.VIEWS);
};
export { up, down };

4
packages/nocodb/src/models/Column.ts

@ -65,6 +65,7 @@ export default class Column<T = any> implements ColumnType {
public column_name: string;
public title: string;
public description: string;
public uidt: UITypes;
public dt: string;
@ -158,6 +159,7 @@ export default class Column<T = any> implements ColumnType {
'system',
'meta',
'virtual',
'description',
]);
if (!insertObj.column_name) {
@ -1229,6 +1231,7 @@ export default class Column<T = any> implements ColumnType {
const updateObj = extractProps(column, [
'column_name',
'title',
'description',
'uidt',
'dt',
'np',
@ -1607,6 +1610,7 @@ export default class Column<T = any> implements ColumnType {
'id',
'fk_model_id',
'column_name',
'description',
'title',
'uidt',
'dt',

16
packages/nocodb/src/models/Model.ts

@ -53,6 +53,7 @@ export default class Model implements TableType {
table_name: string;
title: string;
description?: string;
mm: BoolType;
@ -140,6 +141,7 @@ export default class Model implements TableType {
const insertObj = extractProps(model, [
'table_name',
'title',
'description',
'mm',
'order',
'type',
@ -1096,25 +1098,23 @@ export default class Model implements TableType {
static async updateMeta(
context: NcContext,
tableId: string,
meta: string | Record<string, any>,
model: Pick<TableReqType, 'meta' | 'description'>,
ncMeta = Noco.ncMeta,
) {
const updateObj = extractProps(model, ['description', 'meta']);
// set meta
const res = await ncMeta.metaUpdate(
context.workspace_id,
context.base_id,
MetaTable.MODELS,
prepareForDb({
meta,
}),
prepareForDb(updateObj),
tableId,
);
await NocoCache.update(
`${CacheScope.MODEL}:${tableId}`,
prepareForResponse({
meta,
}),
prepareForResponse(updateObj),
);
return res;
@ -1138,7 +1138,7 @@ export default class Model implements TableType {
hasNonDefaultViews: views.length > 1,
};
await this.updateMeta(context, modelId, modelMeta, ncMeta);
await this.updateMeta(context, modelId, { meta: modelMeta }, ncMeta);
return modelMeta?.hasNonDefaultViews;
}

4
packages/nocodb/src/models/View.ts

@ -61,6 +61,7 @@ type ViewColumnEnrichedWithTitleAndName = ViewColumn & {
export default class View implements ViewType {
id?: string;
title: string;
description?: string;
uuid?: string;
password?: string;
show: boolean;
@ -258,6 +259,7 @@ export default class View implements ViewType {
'id',
'title',
'is_default',
'description',
'type',
'fk_model_id',
'base_id',
@ -1256,6 +1258,7 @@ export default class View implements ViewType {
const updateObj = extractProps(body, [
'title',
'order',
'description',
'show_system_fields',
'lock_type',
'password',
@ -1971,6 +1974,7 @@ export default class View implements ViewType {
'id',
'title',
'is_default',
'description',
'type',
'fk_model_id',
'base_id',

3
packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts

@ -380,9 +380,11 @@ export class ExportService {
prefix: base.prefix,
title: model.title,
table_name: clearPrefix(model.table_name, base.prefix),
description: model.description,
pgSerialLastVal,
meta: model.meta,
columns: model.columns.map((column) => ({
description: column.description,
id: idMap.get(column.id),
ai: column.ai,
column_name: column.column_name,
@ -405,6 +407,7 @@ export class ExportService {
})),
},
views: model.views.map((view) => ({
description: view.description,
id: idMap.get(view.id),
is_default: view.is_default,
type: view.type,

29
packages/nocodb/src/schema/swagger.json

@ -4430,6 +4430,11 @@
"description": "Table title",
"example": "Users"
},
"description": {
"$ref": "#/components/schemas/TextOrNull",
"description": "Table description",
"example": "Table for storing User Information"
},
"base_id": {
"type": "string",
"description": "Base ID",
@ -19668,6 +19673,10 @@
"$ref": "#/components/schemas/Bool",
"description": "Auto Update Timestamp"
},
"description": {
"$ref": "#/components/schemas/TextOrNull",
"description": "Column Description"
},
"source_id": {
"description": "Source ID that this column belongs to",
"example": "ds_krsappzu9f8vmo",
@ -20049,6 +20058,9 @@
"column_name": {
"type": "string"
},
"description": {
"$ref": "#/components/schemas/TextOrNull"
},
"column_order": {
"description": "Column order in a specific view",
"properties": {
@ -25555,6 +25567,10 @@
"description": "Unique Base ID",
"type": "string"
},
"description": {
"description": "Table Description",
"$ref": "#/components/schemas/TextOrNull"
},
"table_name": {
"description": "Table Name. Prefix will be added for XCDB bases.",
"type": "string"
@ -25823,6 +25839,10 @@
},
"type": "array"
},
"description": {
"description": "Table description",
"$ref": "#/components/schemas/TextOrNull"
},
"meta": {
"$ref": "#/components/schemas/Meta",
"description": "the meta data for this table"
@ -26132,6 +26152,10 @@
"description": "The rder of the list of views",
"type": "number"
},
"description": {
"description": "View Description",
"$ref": "#/components/schemas/TextOrNull"
},
"password": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Password for protecting the view"
@ -26442,6 +26466,11 @@
"description": "View Title",
"example": "Grid View 1"
},
"description": {
"$ref": "#/components/schemas/TextOrNull",
"description": "Description of the view.",
"example": "This is a grid view."
},
"uuid": {
"maxLength": 255,
"type": "string",

31
packages/nocodb/src/services/columns.service.ts

@ -201,10 +201,19 @@ export class ColumnsService {
Source.get(context, table.source_id),
);
// TODO: Refactor the columnUpdate function to handle metaOnly changes and
// DB related changes, right now both are mixed up, making this fragile
if (param.column.description !== column.description) {
await Column.update(context, param.columnId, {
description: param.column.description,
});
}
// These are the column types whose meta is allowed to be updated
// It includes currency, date, datetime where formatting is allowed to update
const isMetaOnlyUpdateAllowed =
source?.is_schema_readonly &&
partialUpdateAllowedTypes.includes(column.uidt);
// check if source is readonly and column type is not allowed
if (
source?.is_schema_readonly &&
@ -213,7 +222,23 @@ export class ColumnsService {
!readonlyMetaAllowedTypes.includes(param.column.uidt as UITypes))) &&
!partialUpdateAllowedTypes.includes(column.uidt)
) {
/*
throw error if source is readonly and column type is not allowed
NcError.sourceMetaReadOnly(source.alias);
Get all the columns in the table and return
*/
await table.getColumns(context);
this.appHooksService.emit(AppEvents.COLUMN_UPDATE, {
table,
column,
user: param.req?.user,
ip: param.req?.clientIp,
req: param.req,
});
return table;
}
const sqlClient = await reuseOrSave('sqlClient', reuse, async () =>
@ -222,6 +247,8 @@ export class ColumnsService {
const sqlClientType = sqlClient.knex.clientType();
// The maxLength of column name is different for different databases
// This is the maximum length of column name allowed in the database
const mxColumnLength = Column.getMaxColumnNameLength(sqlClientType);
if (!isVirtualCol(param.column) && !isMetaOnlyUpdateAllowed) {
@ -449,6 +476,7 @@ export class ColumnsService {
meta: colBody.meta,
});
}
if (
'validate' in colBody &&
([UITypes.URL, UITypes.PhoneNumber, UITypes.Email].includes(
@ -1535,6 +1563,7 @@ export class ColumnsService {
});
}
// Get all the columns in the table and return
await table.getColumns(context);
this.appHooksService.emit(AppEvents.COLUMN_UPDATE, {

6
packages/nocodb/src/services/tables.service.ts

@ -69,10 +69,10 @@ export class TablesService {
NcError.badRequest('Model does not belong to base');
}
// if meta present update meta and return
// if meta/description present update and return
// todo: allow user to update meta and other prop in single api call
if ('meta' in param.table) {
await Model.updateMeta(context, param.tableId, param.table.meta);
if ('meta' in param.table || 'description' in param.table) {
await Model.updateMeta(context, param.tableId, param.table);
return true;
}

10
packages/nocodb/tests/unit/rest/tests/typeCasts.test.ts

@ -49,10 +49,12 @@ async function setup(context, base: Base, type: UITypes) {
],
});
const column = (await table.getColumns({
workspace_id: base.fk_workspace_id,
base_id: base.id,
}))[0];
const column = (
await table.getColumns({
workspace_id: base.fk_workspace_id,
base_id: base.id,
})
)[0];
const rowAttributes = [];
for (let i = 0; i < 100; i++) {

4
tests/playwright/tests/db/general/sourceRestrictions.spec.ts

@ -77,8 +77,8 @@ test.describe('Source Restrictions', () => {
.scrollIntoViewIfNeeded();
await dashboard.grid.get().locator(`th[data-title="LastName"]`).first().locator('.nc-ui-dt-dropdown').click();
for (const item of ['Edit', 'Delete', 'Duplicate']) {
await expect(dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).last()).toBeVisible();
await expect(dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).last()).toHaveClass(
await expect(dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).first()).toBeVisible();
await expect(dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).first()).toHaveClass(
/ant-dropdown-menu-item-disabled/
);
}

Loading…
Cancel
Save