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. 54
      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. 80
      packages/nc-gui/components/dlg/TableCreate.vue
  11. 182
      packages/nc-gui/components/dlg/TableDescriptionUpdate.vue
  12. 65
      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. 25
      packages/nc-gui/components/nc/Tooltip.vue
  16. 4
      packages/nc-gui/components/smartsheet/column/DefaultValue.vue
  17. 87
      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. 15
      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. 6
      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

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

@ -45,7 +45,12 @@ const { copy } = useCopy()
const baseRole = inject(ProjectRoleInj) const baseRole = inject(ProjectRoleInj)
provide(SidebarTableInj, table) 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 { loadViews: _loadViews, navigateToView } = useViewsStore()
const { activeView, activeViewTitleOrId, viewsByTable } = storeToRefs(useViewsStore()) const { activeView, activeViewTitleOrId, viewsByTable } = storeToRefs(useViewsStore())
@ -204,6 +209,11 @@ const openRenameTableDialog = (table: SidebarTableNode, sourceId: string) => {
_openRenameTableDialog(table, !!sourceId) _openRenameTableDialog(table, !!sourceId)
} }
const openTableDescriptionDialog = (table: SidebarTableNode) => {
isOptionsOpen.value = false
_openTableDescriptionDialog(table)
}
const deleteTable = () => { const deleteTable = () => {
isOptionsOpen.value = false isOptionsOpen.value = false
isTableDeleteDialogVisible.value = true isTableDeleteDialogVisible.value = true
@ -343,6 +353,16 @@ const source = computed(() => {
{{ table.title }} {{ table.title }}
</span> </span>
</NcTooltip> </NcTooltip>
<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> <NcDropdown v-model:visible="isOptionsOpen" :trigger="['click']" @click.stop>
<NcButton <NcButton
@ -381,6 +401,22 @@ const source = computed(() => {
</div> </div>
</NcTooltip> </NcTooltip>
<NcMenuItem
v-if="
isUIAllowed('tableDescriptionEdit', { roles: baseRole, source }) &&
!isUIAllowed('tableRename', { 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>
<template <template
v-if=" v-if="
!isSharedBase && !isSharedBase &&
@ -401,6 +437,19 @@ const source = computed(() => {
</div> </div>
</NcMenuItem> </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 <NcMenuItem
v-if=" v-if="
isUIAllowed('tableDuplicate', { isUIAllowed('tableDuplicate', {
@ -463,6 +512,7 @@ const source = computed(() => {
</NcButton> </NcButton>
</div> </div>
</div> </div>
</div>
<DlgTableDelete <DlgTableDelete
v-if="table.id && base?.id" v-if="table.id && base?.id"
v-model:visible="isTableDeleteDialogVisible" v-model:visible="isTableDeleteDialogVisible"
@ -480,6 +530,8 @@ const source = computed(() => {
} }
.nc-tree-item svg { .nc-tree-item svg {
&:not(.nc-info-icon) {
@apply text-primary text-opacity-60; @apply text-primary text-opacity-60;
}
} }
</style> </style>

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

@ -63,6 +63,8 @@ const isDefaultBase = computed(() => {
return _isDefaultBase(source) return _isDefaultBase(source)
}) })
const { openViewDescriptionDialog: _openViewDescriptionDialog } = inject(TreeViewInj)!
const input = ref<HTMLInputElement>() const input = ref<HTMLInputElement>()
const isDropdownOpen = ref(false) const isDropdownOpen = ref(false)
@ -193,6 +195,12 @@ async function onRename() {
onStopEdit() onStopEdit()
} }
const openViewDescriptionDialog = (view: ViewType) => {
isDropdownOpen.value = false
_openViewDescriptionDialog(view)
}
/** Cancel renaming view */ /** Cancel renaming view */
function onCancel() { function onCancel() {
if (!isEditing.value) return if (!isEditing.value) return
@ -281,6 +289,15 @@ watch(isDropdownOpen, async () => {
</NcTooltip> </NcTooltip>
<template v-if="!isEditing && !isLocked"> <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"> <NcDropdown v-model:visible="isDropdownOpen" overlay-class-name="!rounded-lg">
<NcButton <NcButton
v-e="['c:view:option']" v-e="['c:view:option']"
@ -305,6 +322,7 @@ watch(isDropdownOpen, async () => {
@close-modal="isDropdownOpen = false" @close-modal="isDropdownOpen = false"
@rename="onRenameMenuClick" @rename="onRenameMenuClick"
@delete="onDelete" @delete="onDelete"
@description-update="openViewDescriptionDialog(vModel)"
/> />
</template> </template>
</NcDropdown> </NcDropdown>

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

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import type { TableType } from 'nocodb-sdk' import type { TableType, ViewType } from 'nocodb-sdk'
import ProjectWrapper from './ProjectWrapper.vue' import ProjectWrapper from './ProjectWrapper.vue'
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
@ -36,6 +36,46 @@ const setMenuContext = (type: 'base' | 'source' | 'table' | 'main' | 'layout', v
contextMenuTarget.value = value 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) { function openRenameTableDialog(table: TableType, _ = false) {
if (!table || !table.source_id) return if (!table || !table.source_id) return
@ -159,6 +199,8 @@ provide(TreeViewInj, {
setMenuContext, setMenuContext,
duplicateTable, duplicateTable,
openRenameTableDialog, openRenameTableDialog,
openViewDescriptionDialog,
openTableDescriptionDialog,
contextMenuTarget, contextMenuTarget,
}) })

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

@ -45,6 +45,13 @@ const { table, createTable, generateUniqueTitle, tables, base } = useTableNew({
const useForm = Form.useForm const useForm = Form.useForm
const enableDescription = ref(false)
const removeDescription = () => {
table.description = ''
enableDescription.value = false
}
const validators = computed(() => { const validators = computed(() => {
return { return {
title: [ 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(() => { onMounted(() => {
generateUniqueTitle() generateUniqueTitle()
nextTick(() => { nextTick(() => {
@ -129,24 +147,27 @@ onMounted(() => {
@keydown.esc="dialogShow = false" @keydown.esc="dialogShow = false"
> >
<template #header> <template #header>
<div class="flex flex-row items-center gap-x-2 text-base text-gray-800"> <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" /> <GeneralIcon icon="table" class="!text-gray-600 w-5 h-5" />
{{ $t('activity.createTable') }} {{ $t('activity.createTable') }}
</div> </div>
<a href="https://docs.nocodb.com/tables/create-table" target="_blank" class="text-[13px]">
{{ $t('title.docs') }}
</a>
</div>
</template> </template>
<div class="flex flex-col mt-1"> <div class="flex flex-col mt-1">
<a-form <a-form
layout="vertical"
:model="table" :model="table"
name="create-new-table-form" name="create-new-table-form"
class="flex flex-col gap-5" class="flex flex-col gap-5"
@keydown.enter="_createTable" @keydown.enter="_createTable"
@keydown.esc="dialogShow = false" @keydown.esc="dialogShow = false"
> >
<div> <div class="flex flex-col gap-5">
<a-form-item <a-form-item v-bind="validateInfos.title">
v-bind="validateInfos.title"
:class="{ '!mb-1': isSnowflake(props.sourceId), '!mb-0': !isSnowflake(props.sourceId) }"
>
<a-input <a-input
ref="inputEl" ref="inputEl"
v-model:value="table.title" v-model:value="table.title"
@ -156,6 +177,31 @@ onMounted(() => {
:placeholder="$t('msg.info.enterTableName')" :placeholder="$t('msg.info.enterTableName')"
/> />
</a-form-item> </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)"> <template v-if="isSnowflake(props.sourceId)">
<a-checkbox v-model:checked="table.is_hybrid" class="!flex flex-row items-center"> Hybrid Table </a-checkbox> <a-checkbox v-model:checked="table.is_hybrid" class="!flex flex-row items-center"> Hybrid Table </a-checkbox>
</template> </template>
@ -188,7 +234,18 @@ onMounted(() => {
</a-row> </a-row>
</div> </div>
</div> </div>
<div class="flex flex-row justify-end gap-x-2"> <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 type="secondary" size="small" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton>
<NcButton <NcButton
@ -203,12 +260,21 @@ onMounted(() => {
<template #loading> {{ $t('title.creatingTable') }} </template> <template #loading> {{ $t('title.creatingTable') }} </template>
</NcButton> </NcButton>
</div> </div>
</div>
</a-form> </a-form>
</div> </div>
</NcModal> </NcModal>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.ant-form-item {
@apply mb-0;
}
.nc-input-text-area {
padding-block: 8px !important;
}
.nc-table-advanced-options { .nc-table-advanced-options {
max-height: 0; max-height: 0;
transition: 0.3s max-height; 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>

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

@ -23,6 +23,7 @@ interface Props {
selectedViewId?: string selectedViewId?: string
groupingFieldColumnId?: string groupingFieldColumnId?: string
geoDataFieldColumnId?: string geoDataFieldColumnId?: string
description?: string
tableId: string tableId: string
calendarRange?: Array<{ calendarRange?: Array<{
fk_from_column_id: string fk_from_column_id: string
@ -40,6 +41,7 @@ interface Emits {
interface Form { interface Form {
title: string title: string
type: ViewTypes type: ViewTypes
description?: string
copy_from_id: string | null copy_from_id: string | null
// for kanban view only // for kanban view only
fk_grp_col_id: string | null fk_grp_col_id: string | null
@ -103,6 +105,7 @@ const form = reactive<Form>({
fk_geo_data_col_id: null, fk_geo_data_col_id: null,
calendar_range: props.calendarRange || [], calendar_range: props.calendarRange || [],
fk_cover_image_col_id: null, fk_cover_image_col_id: null,
description: props.description || '',
}) })
const viewSelectFieldOptions = ref<SelectProps['options']>([]) 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) const isMetaLoading = ref(false)
onMounted(async () => { onMounted(async () => {
if (form.copy_from_id) {
enableDescription.value = true
}
if ([ViewTypes.GALLERY, ViewTypes.KANBAN, ViewTypes.MAP, ViewTypes.CALENDAR].includes(props.type)) { if ([ViewTypes.GALLERY, ViewTypes.KANBAN, ViewTypes.MAP, ViewTypes.CALENDAR].includes(props.type)) {
isMetaLoading.value = true isMetaLoading.value = true
try { try {
meta.value = (await getMeta(tableId.value))! meta.value = (await getMeta(tableId.value))!
if (props.type === ViewTypes.MAP) { if (props.type === ViewTypes.MAP) {
viewSelectFieldOptions.value = meta viewSelectFieldOptions.value = meta
.value!.columns!.filter((el) => el.uidt === UITypes.GeoData) .value!.columns!.filter((el) => el.uidt === UITypes.GeoData)
@ -708,7 +732,39 @@ onMounted(async () => {
</div> </div>
</div> </div>
<div class="flex flex-row w-full justify-end gap-x-2 mt-5"> <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 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"> <NcButton type="secondary" size="small" @click="vModel = false">
{{ $t('general.cancel') }} {{ $t('general.cancel') }}
</NcButton> </NcButton>
@ -726,10 +782,15 @@ onMounted(async () => {
</NcButton> </NcButton>
</div> </div>
</div> </div>
</div>
</NcModal> </NcModal>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
.nc-input-text-area {
padding-block: 8px !important;
}
.ant-form-item-required { .ant-form-item-required {
@apply !text-gray-800 font-medium; @apply !text-gray-800 font-medium;
&:before { &: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-center': props.centered,
'justify-start': !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'"> <template v-if="iconPosition === 'left'">
<GeneralLoader <GeneralLoader

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

@ -35,6 +35,8 @@ const color = computed(() => (props.color ? props.color : 'dark'))
const el = ref() const el = ref()
const element = ref()
const showTooltip = controlledRef(false, { const showTooltip = controlledRef(false, {
onBeforeChange: (shouldShow) => { onBeforeChange: (shouldShow) => {
if (shouldShow && disabled.value) return false if (shouldShow && disabled.value) return false
@ -43,6 +45,8 @@ const showTooltip = controlledRef(false, {
const isHovering = useElementHover(() => el.value) const isHovering = useElementHover(() => el.value)
const isOverlayHovering = useElementHover(() => element.value)
const attrs = useAttrs() const attrs = useAttrs()
const isKeyPressed = ref(false) const isKeyPressed = ref(false)
@ -74,16 +78,25 @@ onKeyStroke(
{ eventName: 'keyup' }, { eventName: 'keyup' },
) )
watch([isHovering, () => modifierKey.value, () => disabled.value], ([hovering, key, isDisabled]) => { watchDebounced(
[isOverlayHovering, isHovering, () => modifierKey.value, () => disabled.value],
([overlayHovering, hovering, key, isDisabled]) => {
if (showOnTruncateOnly?.value) { if (showOnTruncateOnly?.value) {
const targetElement = el?.value const targetElement = el?.value
const isElementTruncated = targetElement && targetElement.scrollWidth > targetElement.clientWidth const isElementTruncated = targetElement && targetElement.scrollWidth > targetElement.clientWidth
if (!isElementTruncated) { if (!isElementTruncated) {
if (overlayHovering) {
showTooltip.value = true
return
}
showTooltip.value = false showTooltip.value = false
return return
} }
} }
if (overlayHovering) {
showTooltip.value = true
return
}
if ((!hovering || isDisabled) && !props.mouseLeaveDelay) { if ((!hovering || isDisabled) && !props.mouseLeaveDelay) {
showTooltip.value = false showTooltip.value = false
return return
@ -105,7 +118,11 @@ watch([isHovering, () => modifierKey.value, () => disabled.value], ([hovering, k
if (!showTooltip.value && hovering && key && isKeyPressed.value) { if (!showTooltip.value && hovering && key && isKeyPressed.value) {
showTooltip.value = true showTooltip.value = true
} }
}) },
{
debounce: 100,
},
)
const divStyles = computed(() => ({ const divStyles = computed(() => ({
style: attrs.style as CSSProperties, style: attrs.style as CSSProperties,
@ -131,7 +148,9 @@ const onClick = () => {
:mouse-leave-delay="mouseLeaveDelay" :mouse-leave-delay="mouseLeaveDelay"
> >
<template #title> <template #title>
<div ref="element">
<slot name="title" /> <slot name="title" />
</div>
</template> </template>
<component <component

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

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

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

@ -25,6 +25,7 @@ const props = defineProps<{
hideType?: boolean hideType?: boolean
hideAdditionalOptions?: boolean hideAdditionalOptions?: boolean
fromTableExplorer?: boolean fromTableExplorer?: boolean
editDescription?: boolean
readonly?: boolean readonly?: boolean
}>() }>()
@ -43,6 +44,8 @@ const {
column, column,
} = useColumnCreateStoreOrThrow() } = useColumnCreateStoreOrThrow()
const editDescription = toRef(props, 'editDescription')
const { getMeta } = useMetas() const { getMeta } = useMetas()
const { t } = useI18n() const { t } = useI18n()
@ -237,7 +240,19 @@ watchEffect(() => {
advancedOptions.value = false advancedOptions.value = false
}) })
const enableDescription = ref(false)
const descInputEl = ref()
const removeDescription = () => {
formState.value.description = ''
enableDescription.value = false
}
onMounted(() => { onMounted(() => {
if (column.value?.description?.length || editDescription.value) {
enableDescription.value = true
}
if (!isEdit.value) { if (!isEdit.value) {
generateNewColumnMeta(true) generateNewColumnMeta(true)
} else { } else {
@ -286,11 +301,15 @@ onMounted(() => {
} }
} }
if (isForm.value && !props.fromTableExplorer) { if (isForm.value && !props.fromTableExplorer && !enableDescription.value) {
setTimeout(() => { setTimeout(() => {
antInput.value?.focus() antInput.value?.focus()
antInput.value?.select() antInput.value?.select()
}, 100) }, 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(() => { const isFullUpdateAllowed = computed(() => {
if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(formState.value?.uidt) && !isVirtualCol(formState.value)) { if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(formState.value?.uidt) && !isVirtualCol(formState.value)) {
return false return false
@ -585,10 +615,57 @@ const isFullUpdateAllowed = computed(() => {
</Transition> </Transition>
</template> </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"> <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>
<template v-else> <template v-else>
<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> <a-form-item>
<div <div
class="flex gap-x-2 justify-end" class="flex gap-x-2 justify-end"
@ -620,6 +697,7 @@ const isFullUpdateAllowed = computed(() => {
</NcButton> </NcButton>
</div> </div>
</a-form-item> </a-form-item>
</div>
</template> </template>
</template> </template>
</a-form> </a-form>
@ -635,6 +713,11 @@ const isFullUpdateAllowed = computed(() => {
</style> </style>
<style lang="scss" scoped> <style lang="scss" scoped>
.nc-input-text-area {
@apply !text-gray-800;
padding-block: 8px !important;
}
.nc-fields-input { .nc-fields-input {
&::placeholder { &::placeholder {
@apply font-normal; @apply font-normal;

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

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

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

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

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

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

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <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 { PlanLimitTypes, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn } from 'nocodb-sdk'
import { SmartsheetStoreEvents, isColumnInvalid } from '#imports' import { SmartsheetStoreEvents, isColumnInvalid } from '#imports'
@ -307,9 +307,9 @@ const handleDelete = () => {
showDeleteColumnModal.value = true showDeleteColumnModal.value = true
} }
const onEditPress = () => { const onEditPress = (event?: MouseEvent, enableDescription = false) => {
isOpen.value = false isOpen.value = false
emit('edit') emit('edit', event, enableDescription)
} }
const onInsertBefore = () => { const onInsertBefore = () => {
@ -401,19 +401,40 @@ const changeTitleField = () => {
isOpen.value = false isOpen.value = false
changeTitleFieldMenu.value = true 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> </script>
<template> <template>
<a-dropdown <a-dropdown
v-if="!isLocked"
v-model:visible="isOpen" v-model:visible="isOpen"
:disabled="isLocked"
:trigger="['click']" :trigger="['click']"
:placement="isExpandedForm ? 'bottomLeft' : 'bottomRight'" :placement="isExpandedForm ? 'bottomLeft' : 'bottomRight'"
overlay-class-name="nc-dropdown-column-operations !border-1 rounded-lg !shadow-xl " 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> <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"> <NcTooltip class="flex items-center">
<GeneralIcon <GeneralIcon
@ -427,7 +448,7 @@ const changeTitleField = () => {
</template> </template>
</NcTooltip> </NcTooltip>
<GeneralIcon <GeneralIcon
v-if="!isExpandedForm" v-if="!isExpandedForm && !isLocked"
icon="arrowDown" icon="arrowDown"
class="text-grey h-full text-grey nc-ui-dt-dropdown cursor-pointer outline-0 mr-2" 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, '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"> <GeneralSourceRestrictionTooltip message="Field properties cannot be edited." :enabled="!isColumnEditAllowed">
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('fieldAlter')" v-if="isUIAllowed('fieldAlter')"
:disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed || linksAssociated.length" :disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed || linksAssociated.length"
:title="linksAssociated.length ? 'Field is associated with a link column' : undefined" :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"> <div class="nc-column-edit nc-header-menu-item">
<component :is="iconMap.ncEdit" class="text-gray-500" /> <component :is="iconMap.ncEdit" class="text-gray-500" />
@ -453,6 +501,31 @@ const changeTitleField = () => {
</div> </div>
</NcMenuItem> </NcMenuItem>
</GeneralSourceRestrictionTooltip> </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 <NcMenuItem
v-if="isUIAllowed('fieldAlter') && !!column?.pv" v-if="isUIAllowed('fieldAlter') && !!column?.pv"
title="Select a new field as display value" title="Select a new field as display value"
@ -463,25 +536,19 @@ const changeTitleField = () => {
{{ $t('labels.changeDisplayValueField') }} {{ $t('labels.changeDisplayValueField') }}
</div> </div>
</NcMenuItem> </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"> <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"> <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" /> <component :is="iconMap.cellLookup" class="text-gray-500 !w-4.5 !h-4.5" />
{{ t('general.addLookupField') }} {{ t('general.addLookupField') }}
</div> </div>
</NcMenuItem> </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" /> <a-divider v-if="isUIAllowed('fieldAlter') && !column?.pv" class="!my-0" />
<NcMenuItem v-if="!column?.pv" @click="hideOrShowField"> <NcMenuItem v-if="!column?.pv" @click="hideOrShowField">
<div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item"> <div v-e="['a:field:hide']" class="nc-column-insert-before nc-header-menu-item">
@ -590,15 +657,6 @@ const changeTitleField = () => {
</NcTooltip> </NcTooltip>
<a-divider class="!my-0" /> <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"> <NcMenuItem @click="onInsertAfter">
<div v-e="['a:field:insert:after']" class="nc-column-insert-after nc-header-menu-item"> <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" /> <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" /> <LazySmartsheetHeaderUpdateDisplayValue v-if="changeTitleFieldMenu" v-model:value="changeTitleFieldMenu" :column="column" />
</template> </template>
<style scoped> <style scoped lang="scss">
:deep(.nc-menu-item-inner) {
@apply !w-full;
}
.nc-header-menu-item { .nc-header-menu-item {
@apply text-dropdown flex items-center gap-2; @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 isDropDownOpen = ref(false)
const enableDescription = ref(false)
const isLocked = inject(IsLockedInj, ref(false)) const isLocked = inject(IsLockedInj, ref(false))
provide(ColumnInj, column) provide(ColumnInj, column)
@ -122,7 +124,13 @@ const closeAddColumnDropdown = () => {
editColumnDropdown.value = false 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 (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if ( if (
@ -131,6 +139,9 @@ const openHeaderMenu = (e?: MouseEvent) => {
!isMobileMode.value && !isMobileMode.value &&
(!isMetaReadOnly.value || readonlyMetaAllowedTypes.includes(column.value.uidt)) (!isMetaReadOnly.value || readonlyMetaAllowedTypes.includes(column.value.uidt))
) { ) {
if (description) {
enableDescription.value = true
}
editColumnDropdown.value = true editColumnDropdown.value = true
} }
} }
@ -150,6 +161,7 @@ const onVisibleChange = () => {
editColumnDropdown.value = true editColumnDropdown.value = true
if (!editOrAddProviderRef.value?.isWebHookModalOpen()) { if (!editOrAddProviderRef.value?.isWebHookModalOpen()) {
editColumnDropdown.value = false editColumnDropdown.value = false
enableDescription.value = false
} }
} }
@ -231,7 +243,7 @@ const onClick = (e: Event) => {
:is-hidden-col="isHiddenCol" :is-hidden-col="isHiddenCol"
:virtual="true" :virtual="true"
@add-column="addField" @add-column="addField"
@edit="editColumnDropdown = true" @edit="openHeaderMenu"
/> />
</template> </template>
@ -253,6 +265,7 @@ const onClick = (e: Event) => {
:column="columnOrder ? null : column" :column="columnOrder ? null : column"
:column-position="columnOrder" :column-position="columnOrder"
class="w-full" class="w-full"
:edit-description="enableDescription"
@submit="closeAddColumnDropdown" @submit="closeAddColumnDropdown"
@cancel="closeAddColumnDropdown" @cancel="closeAddColumnDropdown"
@click.stop @click.stop

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

@ -7,7 +7,7 @@ const { isSharedBase, base } = storeToRefs(useBase())
const { t } = useI18n() const { t } = useI18n()
const { $api } = useNuxtApp() const { $api, $e } = useNuxtApp()
const { refreshCommandPalette } = useCommandPalette() const { refreshCommandPalette } = useCommandPalette()
@ -28,6 +28,28 @@ const viewRenameTitle = ref('')
const error = ref<string | undefined>() 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 = () => { const onRenameMenuClick = () => {
isRenaming.value = true isRenaming.value = true
isDropdownOpen.value = false isDropdownOpen.value = false
@ -190,6 +212,7 @@ function openDeleteDialog() {
@close-modal="isDropdownOpen = false" @close-modal="isDropdownOpen = false"
@rename="onRenameMenuClick" @rename="onRenameMenuClick"
@delete="openDeleteDialog" @delete="openDeleteDialog"
@description-update="updateDescription"
/> />
</template> </template>
</NcDropdown> </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() const { isUIAllowed, isDataReadOnly } = useRoles()
@ -47,6 +47,10 @@ const onRenameMenuClick = () => {
emits('rename') emits('rename')
} }
const onDescriptionUpdateClick = () => {
emits('descriptionUpdate')
}
const quickImportDialogTypes: QuickImportDialogType[] = ['csv', 'excel'] const quickImportDialogTypes: QuickImportDialogType[] = ['csv', 'excel']
const quickImportDialogs: Record<(typeof quickImportDialogTypes)[number], Ref<boolean>> = quickImportDialogTypes.reduce( const quickImportDialogs: Record<(typeof quickImportDialogTypes)[number], Ref<boolean>> = quickImportDialogTypes.reduce(
@ -102,6 +106,7 @@ function onDuplicate() {
'selectedViewId': view.value!.id, 'selectedViewId': view.value!.id,
'groupingFieldColumnId': view.value!.view!.fk_grp_col_id, 'groupingFieldColumnId': view.value!.view!.fk_grp_col_id,
'views': views, 'views': views,
'description': view.value!.description,
'calendarRange': view.value!.view!.calendar_range, 'calendarRange': view.value!.view!.calendar_range,
'coverImageColumnId': view.value!.view!.fk_cover_image_col_id, 'coverImageColumnId': view.value!.view!.fk_cover_image_col_id,
'onUpdate:modelValue': closeDialog, 'onUpdate:modelValue': closeDialog,
@ -195,6 +200,12 @@ const onDelete = async () => {
}} }}
</NcMenuItem> </NcMenuItem>
</NcTooltip> </NcTooltip>
<NcMenuItem v-if="lockType !== LockType.Locked" @click="onDescriptionUpdateClick">
<GeneralIcon icon="ncAlignLeft" />
{{ $t('general.edit') }}
{{ $t('labels.description') }}
</NcMenuItem>
</template> </template>
<NcMenuItem @click="onDuplicate"> <NcMenuItem @click="onDuplicate">
<GeneralIcon class="nc-view-copy-icon" icon="duplicate" /> <GeneralIcon class="nc-view-copy-icon" icon="duplicate" />
@ -205,7 +216,6 @@ const onDelete = async () => {
}} }}
</NcMenuItem> </NcMenuItem>
</template> </template>
<template v-if="view.type !== ViewTypes.FORM"> <template v-if="view.type !== ViewTypes.FORM">
<NcDivider /> <NcDivider />
<template v-if="isUIAllowed('csvTableImport') && !isPublicView && !isDataReadOnly"> <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 defaultType = isMetaReadOnly.value ? UITypes.Formula : UITypes.SingleLineText
const formState = ref<Record<string, any>>({ const formState = ref<Record<string, any>>({
title: '', title: '',
description: '',
uidt: fromTableExplorer?.value ? defaultType : null, uidt: fromTableExplorer?.value ? defaultType : null,
custom: {}, custom: {},
...clone(column.value || {}), ...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' import { generateUniqueTitle as generateTitle } from '#imports'
export function useTableNew(param: { onTableCreate?: (tableMeta: TableType) => void; baseId: string; sourceId?: string }) { 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: '', title: '',
table_name: '', table_name: '',
description: '',
columns: SYSTEM_COLUMNS, columns: SYSTEM_COLUMNS,
is_hybrid: true, is_hybrid: true,
}) })

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

@ -25,6 +25,8 @@ const [useProvideViewAggregate, useViewAggregate] = useInjectionState(
const { nestedFilters } = useSmartsheetStoreOrThrow() const { nestedFilters } = useSmartsheetStoreOrThrow()
const { isUIAllowed } = useRoles()
const { fetchAggregatedData } = useSharedView() const { fetchAggregatedData } = useSharedView()
const aggregations = ref({}) as Ref<Record<string, any>> 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 setMenuContext: (type: 'base' | 'base' | 'table' | 'main' | 'layout', value?: any) => void
duplicateTable: (table: TableType) => void duplicateTable: (table: TableType) => void
openRenameTableDialog: (table: TableType, rightClick: boolean) => void openRenameTableDialog: (table: TableType, rightClick: boolean) => void
openViewDescriptionDialog: (view: ViewType) => void
openTableDescriptionDialog: (table: TableType) => void
contextMenuTarget: { type?: 'base' | 'base' | 'table' | 'main' | 'layout'; value?: any } contextMenuTarget: { type?: 'base' | 'base' | 'table' | 'main' | 'layout'; value?: any }
}> = Symbol('tree-view-functions-injection') }> = Symbol('tree-view-functions-injection')
export const CalendarViewTypeInj: InjectionKey<Ref<'week' | 'month' | 'day' | 'year'>> = Symbol('calendar-view-type-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" "categories": "Categories"
}, },
"labels": { "labels": {
"fieldID": "Field ID",
"addDescription": "Add description",
"editDescription": "Edit description",
"urlFormula": "URL Formula", "urlFormula": "URL Formula",
"selectIcon": "none", "selectIcon": "none",
"selectAWebhook": "--select a webhook--", "selectAWebhook": "--select a webhook--",
@ -1596,6 +1599,9 @@
"tablesMetadataInSync": "Tables metadata is in Sync", "tablesMetadataInSync": "Tables metadata is in Sync",
"addMultipleUsers": "You can add multiple comma(,) separated emails", "addMultipleUsers": "You can add multiple comma(,) separated emails",
"enterTableName": "Enter table name", "enterTableName": "Enter table name",
"enterTableDescription": "Enter table description...",
"enterFieldDescription": "Enter field description...",
"enterViewDescription": "Enter view description...",
"enterLayoutName": "Enter Layout name", "enterLayoutName": "Enter Layout name",
"enterDashboardName": "Enter Dashboard name", "enterDashboardName": "Enter Dashboard name",
"defaultColumns": "Default fields", "defaultColumns": "Default fields",

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

@ -59,12 +59,7 @@ export default {
target="_blank" target="_blank"
rel="noopener noreferrer" 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]" /> <img width="96" alt="NocoDB" src="~/assets/img/brand/nocodb.png" class="flex-none min-w-[96px]" />
</NcTooltip>
</a> </a>
<div class="flex items-center gap-2 text-gray-900 text-sm truncate"> <div class="flex items-center gap-2 text-gray-900 text-sm truncate">
@ -80,6 +75,16 @@ export default {
<span class="truncate"> <span class="truncate">
{{ sharedView?.title }} {{ sharedView?.title }}
</span> </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> </div>
</div> </div>

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

@ -64,6 +64,7 @@ const rolePermissions = {
tableCreate: true, tableCreate: true,
tableRename: true, tableRename: true,
tableDelete: true, tableDelete: true,
tableDescriptionEdit: true,
tableDuplicate: true, tableDuplicate: true,
tableSort: true, tableSort: true,
layoutRename: 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 NcLangJava from '~icons/nc-icons/lang-java.svg'
import NcLangC from '~icons/nc-icons/lang-c.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 // keep it for reference
// todo: remove it after all icons are migrated // todo: remove it after all icons are migrated
/* export const iconMapOld = { /* export const iconMapOld = {
@ -694,6 +700,12 @@ import NcLangC from '~icons/nc-icons/lang-c.svg'
} as const */ } as const */
export const iconMap = { export const iconMap = {
calendar: NcCalendarViewIcon,
grid: NcGridViewIcon,
form: NcFormViewIcon,
gallery: NcGalleryViewIcon,
kanban: NcKanbanViewIcon,
strike: NcStrike, strike: NcStrike,
atSign: NcAtSign, atSign: NcAtSign,
slash: NcSlash, slash: NcSlash,
@ -753,7 +765,7 @@ export const iconMap = {
workspaceDefault: MsGroup, workspaceDefault: MsGroup,
project: Project, project: Project,
search: NcSearch, search: NcSearch,
calendar: Calendar, // calendar: Calendar,
checkCircle: NcCheckCircle, checkCircle: NcCheckCircle,
checkFill: NcCheckFill, checkFill: NcCheckFill,
externalLink: NcExternalLink, externalLink: NcExternalLink,
@ -902,11 +914,11 @@ export const iconMap = {
xml: h('span', { class: 'material-symbols' }, 'code'), xml: h('span', { class: 'material-symbols' }, 'code'),
airtable: LogosAirtable, airtable: LogosAirtable,
excelColored: VscodeIconsExcelColored, excelColored: VscodeIconsExcelColored,
grid: h('span', { class: 'material-symbols' }, 'grid_view'), // grid: h('span', { class: 'material-symbols' }, 'grid_view'),
gallery: h('span', { class: 'material-symbols' }, 'image'), // gallery: h('span', { class: 'material-symbols' }, 'image'),
form: h('span', { class: 'material-symbols' }, 'article'), // form: h('span', { class: 'material-symbols' }, 'article'),
map: h('span', { class: 'material-symbols' }, 'map'), 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'), view: h('span', { class: 'material-symbols' }, 'visibility'),
// rowHeight: h('span', { class: 'material-symbols' }, 'height'), // rowHeight: h('span', { class: 'material-symbols' }, 'height'),
rowHeight: h(PhSplitVerticalThin, { style: { fontSize: '14px' } }), 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_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_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_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 // Create a custom migration source class
export default class XcMigrationSourcev2 { export default class XcMigrationSourcev2 {
@ -103,6 +104,7 @@ export default class XcMigrationSourcev2 {
'nc_057_file_references', 'nc_057_file_references',
'nc_058_button_colum', 'nc_058_button_colum',
'nc_059_invited_by', 'nc_059_invited_by',
'nc_060_descriptions',
]); ]);
} }
@ -208,6 +210,8 @@ export default class XcMigrationSourcev2 {
return nc_058_button_colum; return nc_058_button_colum;
case 'nc_059_invited_by': case 'nc_059_invited_by':
return 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 column_name: string;
public title: string; public title: string;
public description: string;
public uidt: UITypes; public uidt: UITypes;
public dt: string; public dt: string;
@ -158,6 +159,7 @@ export default class Column<T = any> implements ColumnType {
'system', 'system',
'meta', 'meta',
'virtual', 'virtual',
'description',
]); ]);
if (!insertObj.column_name) { if (!insertObj.column_name) {
@ -1229,6 +1231,7 @@ export default class Column<T = any> implements ColumnType {
const updateObj = extractProps(column, [ const updateObj = extractProps(column, [
'column_name', 'column_name',
'title', 'title',
'description',
'uidt', 'uidt',
'dt', 'dt',
'np', 'np',
@ -1607,6 +1610,7 @@ export default class Column<T = any> implements ColumnType {
'id', 'id',
'fk_model_id', 'fk_model_id',
'column_name', 'column_name',
'description',
'title', 'title',
'uidt', 'uidt',
'dt', 'dt',

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

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

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

@ -61,6 +61,7 @@ type ViewColumnEnrichedWithTitleAndName = ViewColumn & {
export default class View implements ViewType { export default class View implements ViewType {
id?: string; id?: string;
title: string; title: string;
description?: string;
uuid?: string; uuid?: string;
password?: string; password?: string;
show: boolean; show: boolean;
@ -258,6 +259,7 @@ export default class View implements ViewType {
'id', 'id',
'title', 'title',
'is_default', 'is_default',
'description',
'type', 'type',
'fk_model_id', 'fk_model_id',
'base_id', 'base_id',
@ -1256,6 +1258,7 @@ export default class View implements ViewType {
const updateObj = extractProps(body, [ const updateObj = extractProps(body, [
'title', 'title',
'order', 'order',
'description',
'show_system_fields', 'show_system_fields',
'lock_type', 'lock_type',
'password', 'password',
@ -1971,6 +1974,7 @@ export default class View implements ViewType {
'id', 'id',
'title', 'title',
'is_default', 'is_default',
'description',
'type', 'type',
'fk_model_id', 'fk_model_id',
'base_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, prefix: base.prefix,
title: model.title, title: model.title,
table_name: clearPrefix(model.table_name, base.prefix), table_name: clearPrefix(model.table_name, base.prefix),
description: model.description,
pgSerialLastVal, pgSerialLastVal,
meta: model.meta, meta: model.meta,
columns: model.columns.map((column) => ({ columns: model.columns.map((column) => ({
description: column.description,
id: idMap.get(column.id), id: idMap.get(column.id),
ai: column.ai, ai: column.ai,
column_name: column.column_name, column_name: column.column_name,
@ -405,6 +407,7 @@ export class ExportService {
})), })),
}, },
views: model.views.map((view) => ({ views: model.views.map((view) => ({
description: view.description,
id: idMap.get(view.id), id: idMap.get(view.id),
is_default: view.is_default, is_default: view.is_default,
type: view.type, type: view.type,

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

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

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

@ -201,10 +201,19 @@ export class ColumnsService {
Source.get(context, table.source_id), 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 = const isMetaOnlyUpdateAllowed =
source?.is_schema_readonly && source?.is_schema_readonly &&
partialUpdateAllowedTypes.includes(column.uidt); partialUpdateAllowedTypes.includes(column.uidt);
// check if source is readonly and column type is not allowed // check if source is readonly and column type is not allowed
if ( if (
source?.is_schema_readonly && source?.is_schema_readonly &&
@ -213,7 +222,23 @@ export class ColumnsService {
!readonlyMetaAllowedTypes.includes(param.column.uidt as UITypes))) && !readonlyMetaAllowedTypes.includes(param.column.uidt as UITypes))) &&
!partialUpdateAllowedTypes.includes(column.uidt) !partialUpdateAllowedTypes.includes(column.uidt)
) { ) {
/*
throw error if source is readonly and column type is not allowed
NcError.sourceMetaReadOnly(source.alias); 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 () => const sqlClient = await reuseOrSave('sqlClient', reuse, async () =>
@ -222,6 +247,8 @@ export class ColumnsService {
const sqlClientType = sqlClient.knex.clientType(); 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); const mxColumnLength = Column.getMaxColumnNameLength(sqlClientType);
if (!isVirtualCol(param.column) && !isMetaOnlyUpdateAllowed) { if (!isVirtualCol(param.column) && !isMetaOnlyUpdateAllowed) {
@ -449,6 +476,7 @@ export class ColumnsService {
meta: colBody.meta, meta: colBody.meta,
}); });
} }
if ( if (
'validate' in colBody && 'validate' in colBody &&
([UITypes.URL, UITypes.PhoneNumber, UITypes.Email].includes( ([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); await table.getColumns(context);
this.appHooksService.emit(AppEvents.COLUMN_UPDATE, { 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'); 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 // todo: allow user to update meta and other prop in single api call
if ('meta' in param.table) { if ('meta' in param.table || 'description' in param.table) {
await Model.updateMeta(context, param.tableId, param.table.meta); await Model.updateMeta(context, param.tableId, param.table);
return true; return true;
} }

6
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({ const column = (
await table.getColumns({
workspace_id: base.fk_workspace_id, workspace_id: base.fk_workspace_id,
base_id: base.id, base_id: base.id,
}))[0]; })
)[0];
const rowAttributes = []; const rowAttributes = [];
for (let i = 0; i < 100; i++) { 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(); .scrollIntoViewIfNeeded();
await dashboard.grid.get().locator(`th[data-title="LastName"]`).first().locator('.nc-ui-dt-dropdown').click(); await dashboard.grid.get().locator(`th[data-title="LastName"]`).first().locator('.nc-ui-dt-dropdown').click();
for (const item of ['Edit', 'Delete', 'Duplicate']) { 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`).first()).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()).toHaveClass(
/ant-dropdown-menu-item-disabled/ /ant-dropdown-menu-item-disabled/
); );
} }

Loading…
Cancel
Save