Browse Source

Nc feat/Readonly source followup (#8795)

* feat: allow partial column update (GUI)

* feat: allow partial column update (backend)

* refactor: swagger schema description correction

* feat: allow edit from multi field editor

* fix: allow meta update in api level

* fix: add tooltip and docs link

* fix: multi field editor corrections

* fix: allow table meta update

* fix: allow table meta update

* fix: allow column validation update

* fix: block adding new option directly from cell

* fix: add tooltip for column menu options

* refactor: tooltips

* test: replace index with count as parameter

* fix: corrections

* refactor: hint text update
pull/8822/head
Pranav C 5 months ago committed by GitHub
parent
commit
d4e5ede2d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      packages/nc-gui/components/cell/MultiSelect.vue
  2. 6
      packages/nc-gui/components/cell/SingleSelect.vue
  3. 2
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  4. 29
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  5. 4
      packages/nc-gui/components/dashboard/TreeView/index.vue
  6. 2
      packages/nc-gui/components/dashboard/settings/DataSources.vue
  7. 53
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  8. 50
      packages/nc-gui/components/dashboard/settings/data-sources/EditBase.vue
  9. 64
      packages/nc-gui/components/dashboard/settings/data-sources/SourceRestrictions.vue
  10. 4
      packages/nc-gui/components/general/BaseLogo.vue
  11. 39
      packages/nc-gui/components/general/SourceRestrictionTooltip.vue
  12. 6
      packages/nc-gui/components/nc/Tooltip.vue
  13. 3
      packages/nc-gui/components/smartsheet/column/DecimalOptions.vue
  14. 21
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  15. 4
      packages/nc-gui/components/smartsheet/column/RatingOptions.vue
  16. 20
      packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue
  17. 18
      packages/nc-gui/components/smartsheet/details/Fields.vue
  18. 11
      packages/nc-gui/components/smartsheet/header/Cell.vue
  19. 24
      packages/nc-gui/components/smartsheet/header/Menu.vue
  20. 7
      packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue
  21. 131
      packages/nc-gui/composables/useColumnCreateStore.ts
  22. 8
      packages/nc-gui/lang/en.json
  23. 92
      packages/nc-gui/utils/treeviewUtils.ts
  24. 23
      packages/nocodb-sdk/src/lib/UITypes.ts
  25. 1
      packages/nocodb-sdk/src/lib/index.ts
  26. 17
      packages/nocodb/src/models/Column.ts
  27. 4
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.controller.ts
  28. 4
      packages/nocodb/src/schema/swagger-v2.json
  29. 12
      packages/nocodb/src/schema/swagger.json
  30. 33
      packages/nocodb/src/services/columns.service.ts
  31. 2
      packages/nocodb/src/services/forms.service.ts
  32. 1
      packages/nocodb/src/services/public-datas.service.ts
  33. 5
      packages/nocodb/src/services/tables.service.ts
  34. 2
      packages/nocodb/src/utils/acl.ts
  35. 6
      tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts
  36. 6
      tests/playwright/tests/db/columns/columnRating.spec.ts
  37. 2
      tests/playwright/tests/db/features/verticalFillHandle.spec.ts
  38. 68
      tests/playwright/tests/db/general/sourceRestrictions.spec.ts

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

@ -55,9 +55,7 @@ const searchVal = ref<string | null>()
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { getMeta } = useMetas() const { isUIAllowed, isMetaReadOnly } = useRoles()
const { isUIAllowed } = useRoles()
const { isPg, isMysql } = useBase() const { isPg, isMysql } = useBase()
@ -522,7 +520,9 @@ const onFocus = () => {
</a-select-option> </a-select-option>
<a-select-option <a-select-option
v-if="searchVal && isOptionMissing && !isPublic && !disableOptionCreation && isUIAllowed('fieldEdit')" v-if="
!isMetaReadOnly && searchVal && isOptionMissing && !isPublic && !disableOptionCreation && isUIAllowed('fieldEdit')
"
:key="searchVal" :key="searchVal"
:value="searchVal" :value="searchVal"
> >

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

@ -49,7 +49,7 @@ const searchVal = ref()
const { getMeta } = useMetas() const { getMeta } = useMetas()
const { isUIAllowed } = useRoles() const { isUIAllowed, isMetaReadOnly } = useRoles()
const { isPg, isMysql } = useBase() const { isPg, isMysql } = useBase()
@ -59,7 +59,9 @@ const tempSelectedOptState = ref<string>()
const isFocusing = ref(false) const isFocusing = ref(false)
const isNewOptionCreateEnabled = computed(() => !isPublic.value && !disableOptionCreation && isUIAllowed('fieldEdit')) const isNewOptionCreateEnabled = computed(
() => !isPublic.value && !disableOptionCreation && isUIAllowed('fieldEdit') && !isMetaReadOnly.value,
)
const options = computed<(SelectOptionType & { value: string })[]>(() => { const options = computed<(SelectOptionType & { value: string })[]>(() => {
if (column?.value.colOptions) { if (column?.value.colOptions) {

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { SourceRestriction, type ViewType } from 'nocodb-sdk' import { type ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
const props = defineProps<{ const props = defineProps<{

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

@ -656,7 +656,7 @@ const getSource = (sourceId: string) => {
v-e="['c:base:expand']" v-e="['c:base:expand']"
type="text" type="text"
size="xxsmall" size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand !xs:opacity-100" class="nc-sidebar-node-btn nc-sidebar-expand !xs:opacity-100 !mr-0 mt-0.5"
:class="{ :class="{
'!opacity-100': isOptionsOpen, '!opacity-100': isOptionsOpen,
}" }"
@ -705,7 +705,7 @@ const getSource = (sourceId: string) => {
v-e="['c:external:base:expand']" v-e="['c:external:base:expand']"
type="text" type="text"
size="xxsmall" size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand !xs:opacity-100" class="nc-sidebar-node-btn nc-sidebar-expand !xs:opacity-100 !mr-0 mt-0.5"
:class="{ '!opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }" :class="{ '!opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }"
> >
<GeneralIcon <GeneralIcon
@ -731,9 +731,21 @@ const getSource = (sourceId: string) => {
class="source-context flex flex-grow items-center gap-1.75 text-gray-800 min-w-1/20 max-w-full" class="source-context flex flex-grow items-center gap-1.75 text-gray-800 min-w-1/20 max-w-full"
@contextmenu="setMenuContext('source', source)" @contextmenu="setMenuContext('source', source)"
> >
<NcTooltip
:tooltip-style="{ 'min-width': 'max-content' }"
:overlay-inner-style="{ 'min-width': 'max-content' }"
:mouse-leave-delay="0.3"
placement="topLeft"
trigger="hover"
>
<template #title>
<component :is="getSourceTooltip(source)" />
</template>
<GeneralBaseLogo <GeneralBaseLogo
class="flex-none min-w-4 !xs:(min-w-4.25 w-4.25 text-sm) !text-gray-600 !group-hover:text-gray-800" :color="getSourceIconColor(source)"
class="flex-none min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)"
/> />
</NcTooltip>
<input <input
v-if="source.id && sourceRenameHelpers[source.id]?.editMode" v-if="source.id && sourceRenameHelpers[source.id]?.editMode"
ref="input" ref="input"
@ -765,17 +777,6 @@ const getSource = (sourceId: string) => {
{{ source.alias || '' }} {{ source.alias || '' }}
</span> </span>
</NcTooltip> </NcTooltip>
<NcTooltip class="xs:(hidden) flex items-center mr-1">
<template #title>{{ $t('objects.externalDb') }}</template>
<GeneralIcon
icon="info"
class="flex-none text-gray-400 hover:text-gray-700 nc-sidebar-node-btn"
:class="{
'!hidden': !isBasesOptionsOpen[source!.id!],
}"
/>
</NcTooltip>
</div> </div>
<div class="flex flex-row items-center gap-x-0.25"> <div class="flex flex-row items-center gap-x-0.25">
<NcDropdown <NcDropdown

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

@ -249,9 +249,9 @@ watch(
ghost-class="ghost" ghost-class="ghost"
@change="onMove($event)" @change="onMove($event)"
> >
<template #item="{ element: base1 }"> <template #item="{ element: baseItem }">
<div :key="base.id"> <div :key="base.id">
<ProjectWrapper :base-role="base1.project_role" :base="base1"> <ProjectWrapper :base-role="baseItem.project_role" :base="baseItem">
<DashboardTreeViewProjectNode /> <DashboardTreeViewProjectNode />
</ProjectWrapper> </ProjectWrapper>
</div> </div>

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

@ -315,7 +315,7 @@ const openedTab = ref('erd')
</template> </template>
<div class="p-6 mt-4 h-full overflow-auto"> <div class="p-6 mt-4 h-full overflow-auto">
<LazyDashboardSettingsDataSourcesEditBase <LazyDashboardSettingsDataSourcesEditBase
class="w-600px" class="w-760px pr-5"
:source-id="activeSource.id" :source-id="activeSource.id"
@source-updated="loadBases(true)" @source-updated="loadBases(true)"
@close="activeSource = null" @close="activeSource = null"

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

@ -1,7 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Form, message } from 'ant-design-vue' import { Form, message } from 'ant-design-vue'
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select' import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import { SourceRestriction } from 'nocodb-sdk'
import { import {
type CertTypes, type CertTypes,
ClientType, ClientType,
@ -437,7 +436,7 @@ const allowDataWrite = computed({
:closable="!creatingSource" :closable="!creatingSource"
:keyboard="!creatingSource" :keyboard="!creatingSource"
:mask-closable="false" :mask-closable="false"
size="medium" :width="750"
@update:visible="toggleModal" @update:visible="toggleModal"
> >
<div class="py-6 px-8"> <div class="py-6 px-8">
@ -454,7 +453,7 @@ const allowDataWrite = computed({
name="external-base-create-form" name="external-base-create-form"
layout="horizontal" layout="horizontal"
no-style no-style
:label-col="{ span: 8 }" :label-col="{ span: 5 }"
> >
<div <div
class="nc-scrollbar-md" class="nc-scrollbar-md"
@ -545,50 +544,10 @@ const allowDataWrite = computed({
<a-input v-model:value="formState.dataSource.searchPath[0]" /> <a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item> </a-form-item>
</template> </template>
<a-form-item> <DashboardSettingsDataSourcesSourceRestrictions
<template #label> v-model:allowMetaWrite="allowMetaWrite"
<div class="flex gap-1 justify-end"> v-model:allowDataWrite="allowDataWrite"
<span> />
{{ $t('labels.allowMetaWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowMetaWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<a-switch v-model:checked="allowMetaWrite" data-testid="nc-allow-meta-write" size="small"></a-switch>
</a-form-item>
<a-form-item>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowDataWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowDataWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<div class="flex justify-start">
<NcTooltip :disabled="!allowMetaWrite" placement="topLeft">
<template #title>
{{ $t('tooltip.dataWriteOptionDisabled') }}
</template>
<a-switch
v-model:checked="allowDataWrite"
:disabled="allowMetaWrite"
data-testid="nc-allow-data-write"
size="small"
></a-switch>
</NcTooltip>
</div>
</a-form-item>
<template <template
v-if=" v-if="
formState.dataSource.client !== ClientType.SQLITE && formState.dataSource.client !== ClientType.SQLITE &&

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

@ -401,7 +401,7 @@ const allowDataWrite = computed({
<div class="edit-source bg-white relative flex flex-col justify-start gap-2 w-full p-2"> <div class="edit-source bg-white relative flex flex-col justify-start gap-2 w-full p-2">
<h1 class="prose-2xl font-bold self-start">{{ $t('activity.editSource') }}</h1> <h1 class="prose-2xl font-bold self-start">{{ $t('activity.editSource') }}</h1>
<a-form ref="form" :model="formState" name="external-base-create-form" layout="horizontal" no-style :label-col="{ span: 8 }"> <a-form ref="form" :model="formState" name="external-base-create-form" layout="horizontal" no-style :label-col="{ span: 5 }">
<div <div
class="nc-scrollbar-md" class="nc-scrollbar-md"
:style="{ :style="{
@ -563,50 +563,10 @@ const allowDataWrite = computed({
<a-input v-model:value="formState.dataSource.searchPath[0]" /> <a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item> </a-form-item>
</template> </template>
<a-form-item> <DashboardSettingsDataSourcesSourceRestrictions
<template #label> v-model:allowMetaWrite="allowMetaWrite"
<div class="flex gap-1 justify-end"> v-model:allowDataWrite="allowDataWrite"
<span> />
{{ $t('labels.allowMetaWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowMetaWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<a-switch v-model:checked="allowMetaWrite" data-testid="nc-allow-meta-write" size="small"></a-switch>
</a-form-item>
<a-form-item>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowDataWrite') }}
</span>
<NcTooltip>
<template #title>
<span>{{ $t('tooltip.allowDataWrite') }}</span>
</template>
<GeneralIcon class="text-gray-500" icon="info" />
</NcTooltip>
</div>
</template>
<div class="flex justify-start">
<NcTooltip :disabled="!allowMetaWrite" placement="topLeft">
<template #title>
{{ $t('tooltip.dataWriteOptionDisabled') }}
</template>
<a-switch
v-model:checked="allowDataWrite"
:disabled="allowMetaWrite"
data-testid="nc-allow-data-write"
size="small"
></a-switch>
</NcTooltip>
</div>
</a-form-item>
<template <template
v-if=" v-if="
formState.dataSource.client !== ClientType.SQLITE && formState.dataSource.client !== ClientType.SQLITE &&

64
packages/nc-gui/components/dashboard/settings/data-sources/SourceRestrictions.vue

@ -0,0 +1,64 @@
<script setup lang="ts">
const props = defineProps<{
allowMetaWrite: boolean
allowDataWrite: boolean
}>()
const emits = defineEmits(['update:allowMetaWrite', 'update:allowDataWrite'])
const dataWrite = useVModel(props, 'allowDataWrite', emits)
const metaWrite = useVModel(props, 'allowMetaWrite', emits)
</script>
<template>
<a-form-item>
<template #help>
<span class="text-small">
{{ $t('tooltip.allowDataWrite') }}
</span>
</template>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowDataWrite') }}
</span>
</div>
</template>
<div class="flex justify-start">
<NcTooltip :disabled="!metaWrite" placement="topLeft">
<template #title>
{{ $t('tooltip.dataWriteOptionDisabled') }}
</template>
<a-switch v-model:checked="dataWrite" :disabled="metaWrite" data-testid="nc-allow-data-write" size="small"></a-switch>
</NcTooltip>
</div>
</a-form-item>
<a-form-item>
<template #help>
<span class="text-small">
<span class="font-weight-medium" :class="{ 'nc-allow-meta-write-help': metaWrite }">
{{ $t('labels.notRecommended') }}:
</span>
{{ $t('tooltip.allowMetaWrite') }}
</span>
</template>
<template #label>
<div class="flex gap-1 justify-end">
<span>
{{ $t('labels.allowMetaWrite') }}
</span>
</div>
</template>
<a-switch v-model:checked="metaWrite" data-testid="nc-allow-meta-write" class="nc-allow-meta-write" size="small"></a-switch>
</a-form-item>
</template>
<style scoped>
.nc-allow-meta-write.ant-switch-checked {
background: #b33870;
}
.nc-allow-meta-write-help {
color: #b33870;
}
</style>

4
packages/nc-gui/components/general/BaseLogo.vue

@ -6,7 +6,7 @@ import SimpleIconsMicrosoftsqlserver from '~icons/simple-icons/microsoftsqlserve
import LogosSnowflakeIcon from '~icons/logos/snowflake-icon' import LogosSnowflakeIcon from '~icons/logos/snowflake-icon'
import MdiDatabaseOutline from '~icons/mdi/database-outline' import MdiDatabaseOutline from '~icons/mdi/database-outline'
const { sourceType } = defineProps<{ sourceType?: string }>() const { sourceType } = defineProps<{ sourceType?: string; color?: string }>()
const baseIcon = computed(() => { const baseIcon = computed(() => {
switch (sourceType) { switch (sourceType) {
@ -27,5 +27,5 @@ const baseIcon = computed(() => {
</script> </script>
<template> <template>
<component :is="baseIcon" /> <component :is="baseIcon" :style="color ? { color } : {}" />
</template> </template>

39
packages/nc-gui/components/general/SourceRestrictionTooltip.vue

@ -0,0 +1,39 @@
<script setup lang="ts">
import type { TooltipPlacement } from 'ant-design-vue/es/tooltip'
import type { CSSProperties } from '@vue/runtime-dom'
defineProps<{
tooltipStyle?: CSSProperties
overlayInnerStyle?: CSSProperties
mouseLeaveDelay?: number
placement?: TooltipPlacement
trigger?: 'hover' | 'click'
message?: string
enabled?: boolean
}>()
</script>
<template>
<NcTooltip
:disabled="!enabled"
:tooltip-style="{ 'min-width': 'max-content' }"
:overlay-inner-style="{ 'min-width': 'max-content' }"
:mouse-leave-delay="0.3"
placement="left"
trigger="hover"
>
<template #title>
{{ $t('tooltip.schemaChangeDisabled') }} <br />
{{ message }}
<br v-if="message" />
<a
class="!text-current"
href="https://docs.nocodb.com/data-sources/connect-to-data-source#configuring-permissions"
target="_blank"
>
Learn more
</a>
</template>
<slot />
</NcTooltip>
</template>

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

@ -14,6 +14,8 @@ interface Props {
hideOnClick?: boolean hideOnClick?: boolean
overlayClassName?: string overlayClassName?: string
wrapChild?: keyof HTMLElementTagNameMap wrapChild?: keyof HTMLElementTagNameMap
mouseLeaveDelay?: number
overlayInnerStyle?: object
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -77,7 +79,7 @@ watch([isHovering, () => modifierKey.value, () => disabled.value], ([hovering, k
} }
} }
if (!hovering || isDisabled) { if ((!hovering || isDisabled) && !props.mouseLeaveDelay) {
showTooltip.value = false showTooltip.value = false
return return
} }
@ -117,9 +119,11 @@ const onClick = () => {
v-model:visible="showTooltip" v-model:visible="showTooltip"
:overlay-class-name="`nc-tooltip ${showTooltip ? 'visible' : 'hidden'} ${overlayClassName}`" :overlay-class-name="`nc-tooltip ${showTooltip ? 'visible' : 'hidden'} ${overlayClassName}`"
:overlay-style="tooltipStyle" :overlay-style="tooltipStyle"
:overlay-inner-style="overlayInnerStyle"
arrow-point-at-center arrow-point-at-center
:trigger="[]" :trigger="[]"
:placement="placement" :placement="placement"
:mouse-leave-delay="mouseLeaveDelay"
> >
<template #title> <template #title>
<slot name="title" /> <slot name="title" />

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

@ -36,6 +36,8 @@ vModel.value.meta = {
const onPrecisionChange = (value: number) => { const onPrecisionChange = (value: number) => {
vModel.value.dtxs = Math.max(value, vModel.value.dtxs) vModel.value.dtxs = Math.max(value, vModel.value.dtxs)
} }
const { isMetaReadOnly } = useRoles()
</script> </script>
<template> <template>
@ -43,6 +45,7 @@ const onPrecisionChange = (value: number) => {
<a-select <a-select
v-if="vModel.meta?.precision" v-if="vModel.meta?.precision"
v-model:value="vModel.meta.precision" v-model:value="vModel.meta.precision"
:disabled="isMetaReadOnly"
dropdown-class-name="nc-dropdown-decimal-format" dropdown-class-name="nc-dropdown-decimal-format"
@change="onPrecisionChange" @change="onPrecisionChange"
> >

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnReqType, ColumnType } from 'nocodb-sdk' import { type ColumnReqType, type ColumnType } from 'nocodb-sdk'
import { import {
UITypes, UITypes,
UITypesName, UITypesName,
@ -343,6 +343,14 @@ const filterOption = (input: string, option: { value: UITypes }) => {
(UITypesName[option.value] && UITypesName[option.value].toLowerCase().includes(input.toLowerCase())) (UITypesName[option.value] && UITypesName[option.value].toLowerCase().includes(input.toLowerCase()))
) )
} }
const isFullUpdateAllowed = computed(() => {
if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(formState.value?.uidt) && !isVirtualCol(formState.value)) {
return false
}
return true
})
</script> </script>
<template> <template>
@ -376,7 +384,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
<input <input
ref="antInput" ref="antInput"
v-model="formState.title" v-model="formState.title"
:disabled="readOnly" :disabled="readOnly || !isFullUpdateAllowed"
:placeholder="`${$t('objects.field')} ${$t('general.name').toLowerCase()} ${isEdit ? '' : $t('labels.optional')}`" :placeholder="`${$t('objects.field')} ${$t('general.name').toLowerCase()} ${isEdit ? '' : $t('labels.optional')}`"
class="flex flex-grow nc-fields-input text-sm font-semibold outline-none bg-inherit min-h-6" class="flex flex-grow nc-fields-input text-sm font-semibold outline-none bg-inherit min-h-6"
:contenteditable="true" :contenteditable="true"
@ -390,7 +398,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
v-model:value="formState.title" v-model:value="formState.title"
class="nc-column-name-input !rounded-lg" class="nc-column-name-input !rounded-lg"
:placeholder="`${$t('objects.field')} ${$t('general.name').toLowerCase()} ${isEdit ? '' : $t('labels.optional')}`" :placeholder="`${$t('objects.field')} ${$t('general.name').toLowerCase()} ${isEdit ? '' : $t('labels.optional')}`"
:disabled="isKanban || readOnly" :disabled="isKanban || readOnly || !isFullUpdateAllowed"
@input="onAlter(8)" @input="onAlter(8)"
/> />
</a-form-item> </a-form-item>
@ -411,10 +419,11 @@ const filterOption = (input: string, option: { value: UITypes }) => {
show-search show-search
class="nc-column-type-input !rounded-lg" class="nc-column-type-input !rounded-lg"
:disabled=" :disabled="
(isMetaReadOnly && !readonlyMetaAllowedTypes.includes(formState.uidt)) || (isEdit && isMetaReadOnly && !readonlyMetaAllowedTypes.includes(formState.uidt)) ||
isKanban || isKanban ||
readOnly || readOnly ||
(isEdit && !!onlyNameUpdateOnEditColumns.includes(column?.uidt)) (isEdit && !!onlyNameUpdateOnEditColumns.includes(column?.uidt)) ||
(isEdit && !isFullUpdateAllowed)
" "
dropdown-class-name="nc-dropdown-column-type border-1 !rounded-lg border-gray-200" dropdown-class-name="nc-dropdown-column-type border-1 !rounded-lg border-gray-200"
:filter-option="filterOption" :filter-option="filterOption"
@ -511,7 +520,7 @@ const filterOption = (input: string, option: { value: UITypes }) => {
</NcSwitch> </NcSwitch>
</div> </div>
<template v-if="!readOnly"> <template v-if="!readOnly && isFullUpdateAllowed">
<div class="nc-column-options-wrapper flex flex-col gap-4"> <div class="nc-column-options-wrapper flex flex-col gap-4">
<!-- <!--
Default Value for JSON & LongText is not supported in MySQL Default Value for JSON & LongText is not supported in MySQL

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

@ -113,13 +113,13 @@ watch(
</a-col> </a-col>
<a-col :span="8"> <a-col :span="8">
<a-form-item :label="$t('labels.max')"> <a-form-item :label="$t('labels.max')">
<a-select v-model:value="vModel.meta.max" class="w-52" dropdown-class-name="nc-dropdown-rating-color"> <a-select v-model:value="vModel.meta.max" data-testid="nc-dropdown-rating-max" class="w-52" dropdown-class-name="nc-dropdown-rating-color">
<template #suffixIcon> <template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700" /> <GeneralIcon icon="arrowDown" class="text-gray-700" />
</template> </template>
<a-select-option v-for="(v, i) in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]" :key="i" :value="v"> <a-select-option v-for="(v, i) in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]" :key="i" :value="v">
<div class="flex gap-2 w-full justify-between items-center"> <div class="flex gap-2 w-full justify-between items-center nc-dropdown-rating-max-option">
{{ v }} {{ v }}
<component <component
:is="iconMap.check" :is="iconMap.check"

20
packages/nc-gui/components/smartsheet/column/UITypesOptionsWithSearch.vue

@ -26,6 +26,10 @@ const inputRef = ref()
const activeFieldIndex = ref(-1) const activeFieldIndex = ref(-1)
const isDisabledUIType = (type: UITypes) => {
return isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(type)
}
const onClick = (uidt: UITypes) => { const onClick = (uidt: UITypes) => {
if (!uidt || isDisabledUIType(uidt)) return if (!uidt || isDisabledUIType(uidt)) return
@ -64,10 +68,6 @@ onMounted(() => {
searchQuery.value = '' searchQuery.value = ''
activeFieldIndex.value = options.value.findIndex((o) => o.name === UITypes.SingleLineText) activeFieldIndex.value = options.value.findIndex((o) => o.name === UITypes.SingleLineText)
}) })
const isDisabledUIType = (type: UITypes) => {
return isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(type)
}
</script> </script>
<template> <template>
@ -100,16 +100,12 @@ const isDisabledUIType = (type: UITypes) => {
{{ options.length ? $t('title.noResultsMatchedYourSearch') : 'The list is empty' }} {{ options.length ? $t('title.noResultsMatchedYourSearch') : 'The list is empty' }}
</div> </div>
<GeneralSourceRestrictionTooltip
<NcTooltip
v-for="(option, index) in filteredOptions" v-for="(option, index) in filteredOptions"
:key="index" :key="index"
:disabled="!isDisabledUIType(option.name)" :message="$t('tooltip.typeNotAllowed')"
placement="left" :enabled="isDisabledUIType(option.name)"
> >
<template #title>
{{ $t('tooltip.typeNotAllowed') }}
</template>
<div <div
class="flex w-full py-2 items-center justify-between px-2 rounded-md" class="flex w-full py-2 items-center justify-between px-2 rounded-md"
:class="[ :class="[
@ -133,7 +129,7 @@ const isDisabledUIType = (type: UITypes) => {
<span v-if="option.deprecated" class="!text-xs !text-gray-300">({{ $t('general.deprecated') }})</span> <span v-if="option.deprecated" class="!text-xs !text-gray-300">({{ $t('general.deprecated') }})</span>
</div> </div>
</div> </div>
</NcTooltip> </GeneralSourceRestrictionTooltip>
</div> </div>
</div> </div>
</template> </template>

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

@ -1,7 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { diff } from 'deep-object-diff' import { diff } from 'deep-object-diff'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol, readonlyMetaAllowedTypes } from 'nocodb-sdk' import {
UITypes,
isLinksOrLTAR,
isSystemColumn,
isVirtualCol,
partialUpdateAllowedTypes,
readonlyMetaAllowedTypes,
} from 'nocodb-sdk'
import type { ColumnType, FilterType, SelectOptionsType } from 'nocodb-sdk' import type { ColumnType, FilterType, SelectOptionsType } from 'nocodb-sdk'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
import { onKeyDown, useMagicKeys } from '@vueuse/core' import { onKeyDown, useMagicKeys } from '@vueuse/core'
@ -185,12 +192,17 @@ const setFieldMoveHook = (field: TableExplorerColumn, before = false) => {
const { isMetaReadOnly } = useRoles() const { isMetaReadOnly } = useRoles()
const isColumnUpdateAllowed = (column: ColumnType) => { const isColumnUpdateAllowed = (column: ColumnType) => {
if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(column?.uidt)) return false if (
isMetaReadOnly.value &&
!readonlyMetaAllowedTypes.includes(column?.uidt) &&
!partialUpdateAllowedTypes.includes(column?.uidt)
)
return false
return true return true
} }
const changeField = (field?: TableExplorerColumn, event?: MouseEvent) => { const changeField = (field?: TableExplorerColumn, event?: MouseEvent) => {
if (!isColumnUpdateAllowed(field)) { if (field?.id && field?.uidt && !isColumnUpdateAllowed(field)) {
return message.info(t('msg.info.schemaReadOnly')) return message.info(t('msg.info.schemaReadOnly'))
} }

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

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { type ColumnReqType, type ColumnType, readonlyMetaAllowedTypes } from 'nocodb-sdk' import { type ColumnReqType, type ColumnType } from 'nocodb-sdk'
import { UITypes, UITypesName } from 'nocodb-sdk' import { UITypes, UITypesName } from 'nocodb-sdk'
interface Props { interface Props {
@ -32,7 +32,7 @@ const isDropDownOpen = ref(false)
const column = toRef(props, 'column') const column = toRef(props, 'column')
const { isUIAllowed, isMetaReadOnly } = useRoles() const { isUIAllowed } = useRoles()
provide(ColumnInj, column) provide(ColumnInj, column)
@ -60,12 +60,7 @@ const closeAddColumnDropdown = () => {
const openHeaderMenu = (e?: MouseEvent) => { const openHeaderMenu = (e?: MouseEvent) => {
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if ( if (!isForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value) {
!isForm.value &&
isUIAllowed('fieldEdit') &&
!isMobileMode.value &&
(!isMetaReadOnly.value || readonlyMetaAllowedTypes.includes(column.value.uidt))
) {
editColumnDropdown.value = true editColumnDropdown.value = true
} }
} }

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

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { type ColumnReqType, readonlyMetaAllowedTypes } from 'nocodb-sdk' import { type ColumnReqType, 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 } from '#imports' import { SmartsheetStoreEvents } from '#imports'
@ -363,6 +363,16 @@ const isColumnUpdateAllowed = computed(() => {
if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(column.value?.uidt)) return false if (isMetaReadOnly.value && !readonlyMetaAllowedTypes.includes(column.value?.uidt)) return false
return true return true
}) })
const isColumnEditAllowed = computed(() => {
if (
isMetaReadOnly.value &&
!readonlyMetaAllowedTypes.includes(column.value?.uidt) &&
!partialUpdateAllowedTypes.includes(column.value?.uidt)
)
return false
return true
})
</script> </script>
<template> <template>
@ -385,9 +395,10 @@ const isColumnUpdateAllowed = computed(() => {
'min-w-[256px]': isExpandedForm, 'min-w-[256px]': isExpandedForm,
}" }"
> >
<GeneralSourceRestrictionTooltip message="Field properties cannot be edited." :enabled="!isColumnEditAllowed">
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('fieldAlter')" v-if="isUIAllowed('fieldAlter')"
:disabled="column?.pk || isSystemColumn(column) || !isColumnUpdateAllowed" :disabled="column?.pk || isSystemColumn(column) || !isColumnEditAllowed"
@click="onEditPress" @click="onEditPress"
> >
<div class="nc-column-edit nc-header-menu-item"> <div class="nc-column-edit nc-header-menu-item">
@ -396,6 +407,9 @@ const isColumnUpdateAllowed = computed(() => {
{{ $t('general.edit') }} {{ $t('general.edit') }}
</div> </div>
</NcMenuItem> </NcMenuItem>
</GeneralSourceRestrictionTooltip>
<GeneralSourceRestrictionTooltip message="Field cannot be duplicated." :enabled="!isDuplicateAllowed">
<NcMenuItem <NcMenuItem
v-if="isUIAllowed('duplicateColumn') && isExpandedForm && !column?.pk" v-if="isUIAllowed('duplicateColumn') && isExpandedForm && !column?.pk"
:disabled="!isDuplicateAllowed" :disabled="!isDuplicateAllowed"
@ -407,6 +421,7 @@ const isColumnUpdateAllowed = computed(() => {
{{ t('general.duplicate') }} {{ t('general.duplicate') }}
</div> </div>
</NcMenuItem> </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">
@ -510,7 +525,7 @@ const isColumnUpdateAllowed = computed(() => {
</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"> <NcMenuItem v-if="!column?.pk" :disabled="!isDuplicateAllowed" @click="openDuplicateDlg">
<div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item"> <div v-e="['a:field:duplicate']" class="nc-column-duplicate nc-header-menu-item">
<component :is="iconMap.duplicate" class="text-gray-700" /> <component :is="iconMap.duplicate" class="text-gray-700" />
@ -518,6 +533,7 @@ const isColumnUpdateAllowed = computed(() => {
{{ t('general.duplicate') }} {{ t('general.duplicate') }}
</div> </div>
</NcMenuItem> </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-700 !w-4.5 !h-4.5" /> <component :is="iconMap.colInsertAfter" class="text-gray-700 !w-4.5 !h-4.5" />
@ -534,6 +550,7 @@ const isColumnUpdateAllowed = computed(() => {
</NcMenuItem> </NcMenuItem>
</template> </template>
<a-divider v-if="!column?.pv" class="!my-0" /> <a-divider v-if="!column?.pv" class="!my-0" />
<GeneralSourceRestrictionTooltip message="Field cannot be deleted." :enabled="!isColumnUpdateAllowed">
<NcMenuItem <NcMenuItem
v-if="!column?.pv && isUIAllowed('fieldDelete')" v-if="!column?.pv && isUIAllowed('fieldDelete')"
:disabled="!isDeleteAllowed || !isColumnUpdateAllowed" :disabled="!isDeleteAllowed || !isColumnUpdateAllowed"
@ -549,6 +566,7 @@ const isColumnUpdateAllowed = computed(() => {
{{ $t('general.delete') }} {{ $t('general.delete') }}
</div> </div>
</NcMenuItem> </NcMenuItem>
</GeneralSourceRestrictionTooltip>
</NcMenu> </NcMenu>
</template> </template>
</a-dropdown> </a-dropdown>

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

@ -214,12 +214,7 @@ const onMove = async (event: { moved: { newIndex: number; oldIndex: number } })
data-testid="nc-group-by-menu" data-testid="nc-group-by-menu"
> >
<div class="max-h-100" @click.stop> <div class="max-h-100" @click.stop>
<Draggable <Draggable :model-value="_groupBy" item-key="fk_column_id" ghost-class="bg-gray-50" @change="onMove($event)">
:model-value="_groupBy"
item-key="fk_column_id"
ghost-class="bg-gray-50"
@change="onMove($event)"
>
<template #item="{ element: group }"> <template #item="{ element: group }">
<div :key="group.fk_column_id" class="flex first:mb-0 !mb-1.5 !last:mb-0 items-center"> <div :key="group.fk_column_id" class="flex first:mb-0 !mb-1.5 !last:mb-0 items-center">
<NcButton type="secondary" size="small" class="!border-r-transparent !rounded-r-none"> <NcButton type="secondary" size="small" class="!border-r-transparent !rounded-r-none">

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

@ -34,6 +34,8 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const { getMeta } = useMetas() const { getMeta } = useMetas()
const { isMetaReadOnly } = useRoles()
const { t } = useI18n() const { t } = useI18n()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
@ -71,12 +73,74 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
postSaveOrUpdateCbk = cbk postSaveOrUpdateCbk = cbk
} }
const defaultType = isMetaReadOnly.value ? UITypes.Formula : UITypes.SingleLineText
const formState = ref<Record<string, any>>({ const formState = ref<Record<string, any>>({
title: '', title: '',
uidt: fromTableExplorer?.value ? UITypes.SingleLineText : null, uidt: fromTableExplorer?.value ? defaultType : null,
...clone(column.value || {}), ...clone(column.value || {}),
}) })
const onUidtOrIdTypeChange = () => {
disableSubmitBtn.value = false
const newTitle = updateFieldName(false)
const colProp = sqlUi.value.getDataTypeForUiType(formState.value as { uidt: UITypes }, idType ?? undefined)
formState.value = {
...(!isEdit.value && {
// only take title, column_name and uidt when creating a column
// to avoid the extra props from being taken (e.g. SingleLineText -> LTAR -> SingleLineText)
// to mess up the column creation
title: newTitle || formState.value.title,
column_name: newTitle || formState.value.column_name,
uidt: formState.value.uidt,
temp_id: formState.value.temp_id,
userHasChangedTitle: !!formState.value?.userHasChangedTitle,
}),
...(isEdit.value && {
// take the existing formState.value when editing a column
// LTAR is not available in this case
...formState.value,
}),
meta: {},
rqd: false,
pk: false,
ai: false,
cdf: null,
un: false,
dtx: 'specificType',
...colProp,
}
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt)
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt)
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.value?.uidt as UITypes)) {
formState.value.dtxp = column.value?.dtxp
}
if (columnToValidate.includes(formState.value.uidt)) {
formState.value.meta = {
validate: formState.value.meta && formState.value.meta.validate,
}
}
// keep length and scale for same datatype
if (column.value && formState.value.uidt === column.value?.uidt) {
formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs
} else {
// default length and scale for currency
if (formState.value?.uidt === UITypes.Currency) {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
}
formState.value.altered = formState.value.altered || 2
}
// actions // actions
const generateNewColumnMeta = (ignoreUidt = false) => { const generateNewColumnMeta = (ignoreUidt = false) => {
setAdditionalValidations({}) setAdditionalValidations({})
@ -87,6 +151,10 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
formState.value.title = '' formState.value.title = ''
formState.value.column_name = '' formState.value.column_name = ''
if (isMetaReadOnly.value) {
formState.value.uidt = defaultType
onUidtOrIdTypeChange()
}
if (ignoreUidt && !fromTableExplorer?.value) { if (ignoreUidt && !fromTableExplorer?.value) {
formState.value.uidt = null formState.value.uidt = null
} }
@ -150,67 +218,6 @@ const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState
const { resetFields, validate, validateInfos } = useForm(formState, validators) const { resetFields, validate, validateInfos } = useForm(formState, validators)
const onUidtOrIdTypeChange = () => {
disableSubmitBtn.value = false
const newTitle = updateFieldName(false)
const colProp = sqlUi.value.getDataTypeForUiType(formState.value as { uidt: UITypes }, idType ?? undefined)
formState.value = {
...(!isEdit.value && {
// only take title, column_name and uidt when creating a column
// to avoid the extra props from being taken (e.g. SingleLineText -> LTAR -> SingleLineText)
// to mess up the column creation
title: newTitle || formState.value.title,
column_name: newTitle || formState.value.column_name,
uidt: formState.value.uidt,
temp_id: formState.value.temp_id,
userHasChangedTitle: !!formState.value?.userHasChangedTitle,
}),
...(isEdit.value && {
// take the existing formState.value when editing a column
// LTAR is not available in this case
...formState.value,
}),
meta: {},
rqd: false,
pk: false,
ai: false,
cdf: null,
un: false,
dtx: 'specificType',
...colProp,
}
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt)
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt)
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.value?.uidt as UITypes)) {
formState.value.dtxp = column.value?.dtxp
}
if (columnToValidate.includes(formState.value.uidt)) {
formState.value.meta = {
validate: formState.value.meta && formState.value.meta.validate,
}
}
// keep length and scale for same datatype
if (column.value && formState.value.uidt === column.value?.uidt) {
formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs
} else {
// default length and scale for currency
if (formState.value?.uidt === UITypes.Currency) {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
}
formState.value.altered = formState.value.altered || 2
}
const onDataTypeChange = () => { const onDataTypeChange = () => {
formState.value.rqd = false formState.value.rqd = false
if (formState.value.uidt !== UITypes.ID) { if (formState.value.uidt !== UITypes.ID) {

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

@ -456,6 +456,7 @@
"looksLikeThisStackIsEmpty": "Looks like this stack does not have any records" "looksLikeThisStackIsEmpty": "Looks like this stack does not have any records"
}, },
"labels": { "labels": {
"notRecommended": "Not recommended",
"allowMetaWrite" : "Allow Schema Edit", "allowMetaWrite" : "Allow Schema Edit",
"allowDataWrite" : "Allow Data Edit", "allowDataWrite" : "Allow Data Edit",
"selectView": "Select a View", "selectView": "Select a View",
@ -1040,10 +1041,11 @@
"group": "Group" "group": "Group"
}, },
"tooltip": { "tooltip": {
"typeNotAllowed": "This datatype is not allowed due to restricted schema alterations for this source.", "schemaChangeDisabled": "Schema editing is disabled for this data source.",
"typeNotAllowed": "This datatype is not allowed.",
"dataWriteOptionDisabled": "Data editing can only be disabled when 'Schema editing' is also disabled.", "dataWriteOptionDisabled": "Data editing can only be disabled when 'Schema editing' is also disabled.",
"allowMetaWrite": "Enable this option to allow modifications to the database schema, including adding, altering, or deleting tables and columns. Use with caution, as changes may affect application functionality.", "allowMetaWrite": "This option allows modification of database schema, including adding, altering, or deleting tables and columns. Use with caution, as changes may impact the structural integrity of your database.",
"allowDataWrite": "Enable this option to allow updating, deleting, or inserting data within the database tables. Ideal for administrative users who need to manage data directly.", "allowDataWrite": "This option allows create, update, or delete of records within database tables. Ideal for administrative users need to change data directly.",
"reachedSourceLimit": "Limited to 10 data sources per base", "reachedSourceLimit": "Limited to 10 data sources per base",
"saveChanges": "Save changes", "saveChanges": "Save changes",
"xcDB": "Create a new base", "xcDB": "Create a new base",

92
packages/nc-gui/utils/treeviewUtils.ts

@ -0,0 +1,92 @@
// based on source restriction decide the icon color
import type { SourceType } from 'nocodb-sdk'
import { clientTypes } from '~/utils/baseCreateUtils'
export const getSourceIconColor = (source: SourceType) => {
if (source.is_schema_readonly && source.is_data_readonly) {
return '#278bff'
}
if (source.is_schema_readonly) {
return '#df830f'
}
return '#de0062'
}
// based on source restriction decide the tooltip message with docs link
export const getSourceTooltip = (source: SourceType) => {
const dbLabel = `Connection type is ${clientTypes.find((c) => c.value === source.type)?.text || source.type?.toUpperCase()}.`
if (source.is_schema_readonly && source.is_data_readonly) {
return h(
'div',
{
className: 'w-max',
},
[
dbLabel,
h('br'),
'Both data and schema editing are disabled.',
h('br'),
'These settings are ideal for read-only use cases of your data.',
h('br'),
h(
'a',
{
className: '!text-current',
href: 'https://docs.nocodb.com/data-sources/connect-to-data-source#configuring-permissions',
target: '_blank',
},
'Learn more',
),
],
)
}
if (source.is_schema_readonly) {
return h(
'div',
{
className: 'max-w-90',
},
[
dbLabel,
h('br'),
'Data editing is allowed and Schema edit is not allowed.',
h('br'),
'An ideal settings for administrative users who need to change data directly on database.',
h('br'),
h(
'a',
{
className: '!text-current',
href: 'https://docs.nocodb.com/data-sources/connect-to-data-source#configuring-permissions',
target: '_blank',
},
'Learn more',
),
],
)
}
return h(
'div',
{
className: 'max-w-90',
},
[
dbLabel,
h('br'),
'Both Data and Schema Editing are enabled.',
h('br'),
'We highly recommend ',
h(
'a',
{
className: '!text-current',
href: 'https://docs.nocodb.com/data-sources/connect-to-data-source#configuring-permissions',
target: '_blank',
},
'disabling schema editing',
),
' to maintain data integrity and avoid potential issues.',
],
)
}

23
packages/nocodb-sdk/src/lib/UITypes.ts

@ -262,3 +262,26 @@ export const readonlyMetaAllowedTypes = [
UITypes.Barcode, UITypes.Barcode,
UITypes.QrCode, UITypes.QrCode,
]; ];
export const partialUpdateAllowedTypes = [
// Single/Multi select is disabled for now since it involves updating type in some cases
// UITypes.SingleSelect,
// UITypes.MultiSelect,
UITypes.Checkbox,
UITypes.Number,
UITypes.Decimal,
UITypes.Currency,
UITypes.Percent,
UITypes.Duration,
UITypes.Rating,
UITypes.DateTime,
UITypes.Date,
UITypes.Time,
UITypes.CreatedTime,
UITypes.LastModifiedTime,
UITypes.LinkToAnotherRecord,
UITypes.Links,
UITypes.PhoneNumber,
UITypes.Email,
UITypes.URL,
];

1
packages/nocodb-sdk/src/lib/index.ts

@ -21,6 +21,7 @@ export {
getEquivalentUIType, getEquivalentUIType,
isSelectTypeCol, isSelectTypeCol,
readonlyMetaAllowedTypes, readonlyMetaAllowedTypes,
partialUpdateAllowedTypes
} from '~/lib/UITypes'; } from '~/lib/UITypes';
export { default as CustomAPI, FileType } from '~/lib/CustomAPI'; export { default as CustomAPI, FileType } from '~/lib/CustomAPI';
export { default as TemplateGenerator } from '~/lib/TemplateGenerator'; export { default as TemplateGenerator } from '~/lib/TemplateGenerator';

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

@ -1387,6 +1387,23 @@ export default class Column<T = any> implements ColumnType {
); );
} }
static async updateValidation(
context: NcContext,
{ colId, validate }: { colId: string; validate: any },
ncMeta = Noco.ncMeta,
) {
// set meta
await ncMeta.metaUpdate(
context.workspace_id,
context.base_id,
MetaTable.COLUMNS,
prepareForDb({ validate }, 'validate'),
colId,
);
await NocoCache.update(`${CacheScope.COLUMN}:${colId}`, { validate });
}
static async updateTargetView( static async updateTargetView(
context: NcContext, context: NcContext,
{ colId, fk_target_view_id }: { colId: string; fk_target_view_id: string }, { colId, fk_target_view_id }: { colId: string; fk_target_view_id: string },

4
packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.controller.ts

@ -11,9 +11,7 @@ import {
import { import {
ProjectStatus, ProjectStatus,
readonlyMetaAllowedTypes, readonlyMetaAllowedTypes,
SourceRestriction,
} from 'nocodb-sdk'; } from 'nocodb-sdk';
import type { UITypes } from 'nocodb-sdk';
import { GlobalGuard } from '~/guards/global/global.guard'; import { GlobalGuard } from '~/guards/global/global.guard';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { BasesService } from '~/services/bases.service'; import { BasesService } from '~/services/bases.service';
@ -293,7 +291,7 @@ export class DuplicateController {
const source = await Source.get(context, model.source_id); const source = await Source.get(context, model.source_id);
// check if source is readonly and column type is not allowed // check if source is readonly and column type is not allowed
if (!readonlyMetaAllowedTypes.includes(column.uidt as UITypes)) { if (!readonlyMetaAllowedTypes.includes(column.uidt)) {
if (source.is_schema_readonly) { if (source.is_schema_readonly) {
NcError.sourceMetaReadOnly(source.alias); NcError.sourceMetaReadOnly(source.alias);
} }

4
packages/nocodb/src/schema/swagger-v2.json

@ -12168,11 +12168,11 @@
}, },
"is_schema_readonly": { "is_schema_readonly": {
"$ref": "#/components/schemas/Bool", "$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db" "description": "Is the data source schema readonly"
}, },
"is_data_readonly": { "is_data_readonly": {
"$ref": "#/components/schemas/Bool", "$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db" "description": "Is the data source data readonly"
}, },
"order": { "order": {
"description": "The order of the list of sources", "description": "The order of the list of sources",

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

@ -18217,11 +18217,11 @@
}, },
"is_schema_readonly": { "is_schema_readonly": {
"$ref": "#/components/schemas/Bool", "$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db" "description": "Is the data source schema readonly"
}, },
"is_data_readonly": { "is_data_readonly": {
"$ref": "#/components/schemas/Bool", "$ref": "#/components/schemas/Bool",
"description": "Is the data source minimal db" "description": "Is the data source data readonly"
}, },
"order": { "order": {
"description": "The order of the list of sources", "description": "The order of the list of sources",
@ -18397,6 +18397,14 @@
"description": "Is the data source minimal db", "description": "Is the data source minimal db",
"type": "boolean" "type": "boolean"
}, },
"is_schema_readonly": {
"$ref": "#/components/schemas/Bool",
"description": "Is the data source schema readonly"
},
"is_data_readonly": {
"$ref": "#/components/schemas/Bool",
"description": "Is the data source data readonly"
},
"type": { "type": {
"description": "DB Type", "description": "DB Type",
"enum": [ "enum": [

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

@ -5,6 +5,7 @@ import {
isCreatedOrLastModifiedTimeCol, isCreatedOrLastModifiedTimeCol,
isLinksOrLTAR, isLinksOrLTAR,
isVirtualCol, isVirtualCol,
partialUpdateAllowedTypes,
readonlyMetaAllowedTypes, readonlyMetaAllowedTypes,
RelationTypes, RelationTypes,
SourceRestriction, SourceRestriction,
@ -199,12 +200,17 @@ export class ColumnsService {
Source.get(context, table.source_id), Source.get(context, table.source_id),
); );
const isMetaOnlyUpdateAllowed =
source?.is_schema_readonly &&
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 &&
(!readonlyMetaAllowedTypes.includes(column.uidt) || (!readonlyMetaAllowedTypes.includes(column.uidt) ||
(param.column.uidt && (param.column.uidt &&
!readonlyMetaAllowedTypes.includes(param.column.uidt as UITypes))) !readonlyMetaAllowedTypes.includes(param.column.uidt as UITypes))) &&
!partialUpdateAllowedTypes.includes(column.uidt)
) { ) {
NcError.sourceMetaReadOnly(source.alias); NcError.sourceMetaReadOnly(source.alias);
} }
@ -217,7 +223,7 @@ export class ColumnsService {
const mxColumnLength = Column.getMaxColumnNameLength(sqlClientType); const mxColumnLength = Column.getMaxColumnNameLength(sqlClientType);
if (!isVirtualCol(param.column)) { if (!isVirtualCol(param.column) && !isMetaOnlyUpdateAllowed) {
param.column.column_name = sanitizeColumnName( param.column.column_name = sanitizeColumnName(
param.column.column_name, param.column.column_name,
source.type, source.type,
@ -229,7 +235,7 @@ export class ColumnsService {
param.column.title = param.column.title.trim(); param.column.title = param.column.title.trim();
} }
if (param.column.column_name) { if (param.column.column_name && !isMetaOnlyUpdateAllowed) {
// - 5 is a buffer for suffix // - 5 is a buffer for suffix
let colName = param.column.column_name.slice(0, mxColumnLength - 5); let colName = param.column.column_name.slice(0, mxColumnLength - 5);
let suffix = 1; let suffix = 1;
@ -247,6 +253,7 @@ export class ColumnsService {
} }
if ( if (
!isMetaOnlyUpdateAllowed &&
!isVirtualCol(param.column) && !isVirtualCol(param.column) &&
param.column.column_name.length > mxColumnLength param.column.column_name.length > mxColumnLength
) { ) {
@ -291,6 +298,7 @@ export class ColumnsService {
} & Partial<Pick<ColumnReqType, 'column_order'>>; } & Partial<Pick<ColumnReqType, 'column_order'>>;
if ( if (
isMetaOnlyUpdateAllowed ||
isCreatedOrLastModifiedTimeCol(column) || isCreatedOrLastModifiedTimeCol(column) ||
isCreatedOrLastModifiedByCol(column) || isCreatedOrLastModifiedByCol(column) ||
[ [
@ -365,15 +373,28 @@ export class ColumnsService {
} }
if ( if (
'meta' in colBody && 'meta' in colBody &&
[UITypes.CreatedTime, UITypes.LastModifiedTime].includes( ([UITypes.CreatedTime, UITypes.LastModifiedTime].includes(
column.uidt, column.uidt,
) ) ||
isMetaOnlyUpdateAllowed)
) { ) {
await Column.updateMeta(context, { await Column.updateMeta(context, {
colId: param.columnId, colId: param.columnId,
meta: colBody.meta, meta: colBody.meta,
}); });
} }
if (
'validate' in colBody &&
([UITypes.URL, UITypes.PhoneNumber, UITypes.Email].includes(
column.uidt,
) ||
isMetaOnlyUpdateAllowed)
) {
await Column.updateValidation(context, {
colId: param.columnId,
validate: colBody.validate,
});
}
if (isLinksOrLTAR(column)) { if (isLinksOrLTAR(column)) {
if ('meta' in colBody) { if ('meta' in colBody) {
@ -436,7 +457,7 @@ export class ColumnsService {
await this.updateRollupOrLookup(context, colBody, column); await this.updateRollupOrLookup(context, colBody, column);
} else { } else {
NcError.notImplemented(`Updating ${colBody.uidt} => ${colBody.uidt}`); NcError.notImplemented(`Updating ${column.uidt} => ${colBody.uidt}`);
} }
} else if ( } else if (
[ [

2
packages/nocodb/src/services/forms.service.ts

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AppEvents, SourceRestriction, ViewTypes } from 'nocodb-sdk'; import { AppEvents, ViewTypes } from 'nocodb-sdk';
import type { import type {
FormUpdateReqType, FormUpdateReqType,
UserType, UserType,

1
packages/nocodb/src/services/public-datas.service.ts

@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { import {
populateUniqueFileName, populateUniqueFileName,
SourceRestriction,
UITypes, UITypes,
ViewTypes, ViewTypes,
} from 'nocodb-sdk'; } from 'nocodb-sdk';

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

@ -76,6 +76,11 @@ export class TablesService {
return true; return true;
} }
// allow user to only update meta json data when source is restricted changes to schema
if (source?.is_schema_readonly) {
NcError.sourceMetaReadOnly(source.alias);
}
if (!param.table.table_name) { if (!param.table.table_name) {
NcError.badRequest( NcError.badRequest(
'Missing table name `table_name` property in request body', 'Missing table name `table_name` property in request body',

2
packages/nocodb/src/utils/acl.ts

@ -456,8 +456,6 @@ export const sourceRestrictions = {
[SourceRestriction.SCHEMA_READONLY]: { [SourceRestriction.SCHEMA_READONLY]: {
tableCreate: true, tableCreate: true,
tableDelete: true, tableDelete: true,
tableUpdate: true,
columnBulk: true,
}, },
[SourceRestriction.DATA_READONLY]: { [SourceRestriction.DATA_READONLY]: {
dataUpdate: true, dataUpdate: true,

6
tests/playwright/pages/Dashboard/common/Cell/RatingCell.ts

@ -17,7 +17,11 @@ export class RatingCellPageObject extends BasePage {
async select({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) { async select({ index, columnHeader, rating }: { index?: number; columnHeader: string; rating: number }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded(); await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.waitForResponse({ await this.waitForResponse({
uiAction: async () => await this.get({ index, columnHeader }).locator('.ant-rate-star > div').nth(rating).click(), uiAction: async () =>
await this.get({ index, columnHeader })
.locator('.ant-rate-star > div')
.nth(rating - 1)
.click(),
httpMethodsToMatch: ['POST', 'PATCH'], httpMethodsToMatch: ['POST', 'PATCH'],
requestUrlPathToMatch: 'api/v1/db/data/noco/', requestUrlPathToMatch: 'api/v1/db/data/noco/',
}); });

6
tests/playwright/tests/db/columns/columnRating.spec.ts

@ -66,9 +66,9 @@ test.describe('Rating - cell, filter, sort', () => {
}); });
// In cell insert // In cell insert
await dashboard.grid.cell.rating.select({ index: 0, columnHeader: 'rating', rating: 2 }); await dashboard.grid.cell.rating.select({ index: 0, columnHeader: 'rating', rating: 3 });
await dashboard.grid.cell.rating.select({ index: 2, columnHeader: 'rating', rating: 1 }); await dashboard.grid.cell.rating.select({ index: 2, columnHeader: 'rating', rating: 2 });
await dashboard.grid.cell.rating.select({ index: 5, columnHeader: 'rating', rating: 0 }); await dashboard.grid.cell.rating.select({ index: 5, columnHeader: 'rating', rating: 1 });
// column values // column values
// 1a : 3 // 1a : 3

2
tests/playwright/tests/db/features/verticalFillHandle.spec.ts

@ -113,7 +113,7 @@ test.describe('Fill Handle', () => {
await p.dashboard.grid.cell.time.set({ index: 0, columnHeader: 'Time', value: '02:02' }); await p.dashboard.grid.cell.time.set({ index: 0, columnHeader: 'Time', value: '02:02' });
// set rating for first record // set rating for first record
await p.dashboard.grid.cell.rating.select({ index: 0, columnHeader: 'Rating', rating: 2 }); await p.dashboard.grid.cell.rating.select({ index: 0, columnHeader: 'Rating', rating: 3 });
await dragDrop({ firstColumn: 'Number', lastColumn: 'Time', params: p }); await dragDrop({ firstColumn: 'Number', lastColumn: 'Time', params: p });

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

@ -80,12 +80,70 @@ 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`).last()).toHaveClass(
/ant-dropdown-menu-item-disabled/
);
}
});
test('Readonly schema source - edit column', async () => {
await dashboard.treeView.openTable({
title: 'Country',
});
// Create Rating column
await dashboard.grid.column.create({
title: 'Rating',
type: 'Rating',
});
await dashboard.treeView.openProjectSourceSettings({ title: context.base.title, context });
await settingsPage.selectTab({ tab: 'dataSources' });
await dashboard.rootPage.waitForTimeout(300);
await settingsPage.source.updateSchemaReadOnly({ sourceName: 'Default', readOnly: true });
await settingsPage.close();
// reload page to reflect source changes
await dashboard.rootPage.reload();
await dashboard.treeView.verifyTable({ title: 'Country' });
// open table and verify that it is readonly
await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.grid
.get()
.locator(`th[data-title="Rating"]`)
.first()
.locator('.nc-ui-dt-dropdown')
.scrollIntoViewIfNeeded();
await dashboard.grid.get().locator(`th[data-title="Rating"]`).first().locator('.nc-ui-dt-dropdown').click();
for (const item of ['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(
/ant-dropdown-menu-item-disabled/
);
}
await expect(await dashboard.rootPage.locator(`li[role="menuitem"]:has-text("Edit"):visible`).last()).toBeVisible();
await dashboard.rootPage.locator(`li[role="menuitem"]:has-text("Edit"):visible`).last().click();
await dashboard.rootPage.waitForTimeout(300);
await expect( await expect(
await dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).last() dashboard.rootPage.locator(`.nc-dropdown-edit-column .ant-form-item-label:has-text("Icon")`).last()
).toBeVisible(); ).toBeVisible();
await expect(
await dashboard.rootPage.locator(`li[role="menuitem"]:has-text("${item}"):visible`).last() await dashboard.rootPage.locator(`.nc-dropdown-edit-column`).getByTestId('nc-dropdown-rating-max').click();
).toHaveClass(/ant-dropdown-menu-item-disabled/);
} await dashboard.rootPage.locator(`.nc-dropdown-rating-max-option:has-text("9")`).click();
await dashboard.grid.column.save({ isUpdated: true });
await dashboard.grid.cell.rating.select({ index: 0, columnHeader: 'Rating', rating: 6 });
await dashboard.grid.cell.rating.verify({ index: 0, columnHeader: 'Rating', rating: 6 });
}); });
}); });

Loading…
Cancel
Save