Browse Source

feat: user field (WIP)

pull/7202/head
mertmit 12 months ago
parent
commit
a7405aec7d
  1. 419
      packages/nc-gui/components/cell/User.vue
  2. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  3. 1
      packages/nc-gui/components/smartsheet/column/EditOrAdd.vue
  4. 53
      packages/nc-gui/components/smartsheet/column/UserOptions.vue
  5. 6
      packages/nc-gui/components/smartsheet/header/CellIcon.ts
  6. 1
      packages/nc-gui/lib/types.ts
  7. 1
      packages/nc-gui/utils/cell.ts
  8. 4
      packages/nc-gui/utils/columnUtils.ts
  9. 5
      packages/nc-gui/utils/iconUtils.ts
  10. 1
      packages/nocodb-sdk/src/lib/UITypes.ts
  11. 178
      packages/nocodb/src/db/BaseModelSqlv2.ts
  12. 6
      packages/nocodb/src/schema/swagger-v2.json
  13. 6
      packages/nocodb/src/schema/swagger.json
  14. 128
      packages/nocodb/src/services/columns.service.ts

419
packages/nc-gui/components/cell/User.vue

@ -0,0 +1,419 @@
<script lang="ts" setup>
import { onUnmounted } from '@vue/runtime-core'
import tinycolor from 'tinycolor2'
import type { Select as AntSelect } from 'ant-design-vue'
import {
ActiveCellInj,
CellClickHookInj,
ColumnInj,
EditColumnInj,
EditModeInj,
IsKanbanInj,
ReadonlyInj,
RowHeightInj,
computed,
h,
inject,
isDrawerOrModalExist,
onMounted,
ref,
useEventListener,
useRoles,
useSelectedCellKeyupListener,
watch,
} from '#imports'
import MdiCloseCircle from '~icons/mdi/close-circle'
interface Props {
modelValue?: { id: string; email: string; display_name: string }[] | null
rowIndex?: number
location?: 'cell' | 'filter'
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMobileMode } = useGlobal()
const column = inject(ColumnInj)!
const readOnly = inject(ReadonlyInj)!
const isEditable = inject(EditModeInj, ref(false))
const activeCell = inject(ActiveCellInj, ref(false))
const workspaceStore = useWorkspace()
const { activeWorkspace } = storeToRefs(workspaceStore)
// use both ActiveCellInj or EditModeInj to determine the active state
// since active will be false in case of form view
const active = computed(() => activeCell.value || isEditable.value)
const isForm = inject(IsFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const isMultiple = computed(() => (column.value.meta as { is_multi: boolean; notify: boolean })?.is_multi)
const rowHeight = inject(RowHeightInj, ref(undefined))
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const searchVal = ref<string | null>()
const { isUIAllowed } = useRoles()
const options = computed<{ id: string; email: string; display_name: string }[]>(() => {
const collaborators: { id: string; email: string; display_name: string }[] = []
collaborators.push(
...(activeWorkspace.value.collaborators?.map((user: any) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
})) || []),
)
collaborators.push(
...(modelValue
?.filter((user) => {
const userExists = collaborators.find((u) => u.id === user.id)
return !userExists
})
.map((user) => ({
id: user.id,
email: user.email,
display_name: user.display_name,
})) || []),
)
return collaborators
})
const hasEditRoles = computed(() => isUIAllowed('dataEdit'))
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value)
const vModel = computed({
get: () => {
const selected = modelValue?.reduce((acc, item) => {
const label = item?.display_name || item?.email
if (label) {
acc.push({
label,
value: item.id,
})
}
return acc
}, [] as { label: string; value: string }[])
return selected
},
set: (val) => {
const value: { id: string; email: string; display_name: string }[] = []
if (val && val.length) {
val.forEach((item) => {
// @ts-expect-error antd select returns string[] instead of { label: string, value: string }[]
const user = options.value.find((u) => u.id === item)
if (user) {
value.push(user)
}
})
}
if (isMultiple.value) {
emit('update:modelValue', val?.length ? value : null)
} else {
emit('update:modelValue', val?.length ? value[value.length - 1] : null)
isOpen.value = false
}
},
})
watch(isOpen, (n, _o) => {
if (!n) searchVal.value = ''
if (editAllowed.value) {
if (!n) {
aselect.value?.$el?.querySelector('input')?.blur()
} else {
aselect.value?.$el?.querySelector('input')?.focus()
}
}
})
useSelectedCellKeyupListener(activeCell, (e) => {
switch (e.key) {
case 'Escape':
isOpen.value = false
break
case 'Enter':
if (editAllowed.value && active.value && !isOpen.value) {
isOpen.value = true
}
break
// skip space bar key press since it's used for expand row
case ' ':
break
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowLeft':
case 'Delete':
// skip
break
default:
if (!editAllowed.value) {
e.preventDefault()
break
}
// toggle only if char key pressed
if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1 && !isDrawerOrModalExist()) {
e.stopPropagation()
isOpen.value = true
}
break
}
})
// close dropdown list on escape
useSelectedCellKeyupListener(isOpen, (e) => {
if (e.key === 'Escape') isOpen.value = false
})
const search = () => {
searchVal.value = aselect.value?.$el?.querySelector('.ant-select-selection-search-input')?.value
}
const onTagClick = (e: Event, onClose: Function) => {
// check clicked element is remove icon
if (
(e.target as HTMLElement)?.classList.contains('ant-tag-close-icon') ||
(e.target as HTMLElement)?.closest('.ant-tag-close-icon')
) {
e.stopPropagation()
onClose()
}
}
const cellClickHook = inject(CellClickHookInj, null)
const toggleMenu = () => {
if (cellClickHook) return
isOpen.value = editAllowed.value && !isOpen.value
}
const cellClickHookHandler = () => {
isOpen.value = editAllowed.value && !isOpen.value
}
onMounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
onUnmounted(() => {
cellClickHook?.on(cellClickHookHandler)
})
const handleClose = (e: MouseEvent) => {
// close dropdown if clicked outside of dropdown
if (
isOpen.value &&
aselect.value &&
!aselect.value.$el.contains(e.target) &&
!document.querySelector('.nc-dropdown-multi-select-cell.active')?.contains(e.target as Node)
) {
// loose focus when clicked outside
isEditable.value = false
isOpen.value = false
}
}
useEventListener(document, 'click', handleClose, true)
// search with email
const filterOption = (input: string, option: any) => {
const email = options.value.find((o) => o.id === option.value)?.email
if (email) {
return email.toLowerCase().includes(input.toLowerCase())
}
}
</script>
<template>
<div class="nc-multi-select h-full w-full flex items-center" :class="{ 'read-only': readOnly }" @click="toggleMenu">
<div
v-if="!active"
class="flex flex-wrap"
:style="{
'display': '-webkit-box',
'max-width': '100%',
'-webkit-line-clamp': rowHeight || 1,
'-webkit-box-orient': 'vertical',
'overflow': 'hidden',
}"
>
<template v-for="selectedOpt of vModel" :key="selectedOpt.value">
<a-tag class="rounded-tag" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ selectedOpt.label }}
</span>
</a-tag>
</template>
</div>
<a-select
v-else
ref="aselect"
v-model:value="vModel"
mode="multiple"
class="w-full overflow-hidden"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:bordered="false"
clear-icon
:show-search="!isMobileMode"
:show-arrow="editAllowed && !readOnly"
:open="isOpen && editAllowed"
:disabled="readOnly || !editAllowed"
:class="{ 'caret-transparent': !hasEditRoles }"
:dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`"
:filter-option="filterOption"
@search="search"
@keydown.stop
>
<template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-700 nc-select-expand-btn" />
</template>
<a-select-option
v-for="op of options"
:key="op.id || op.email"
:value="op.id"
:data-testid="`select-option-${column.title}-${location === 'filter' ? 'filter' : rowIndex}`"
:class="`nc-select-option-${column.title}-${op.email}`"
@click.stop
>
<a-tag class="rounded-tag" color="'#ccc'">
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', { level: 'AA', size: 'large' })
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ op.display_name?.length ? op.display_name : op.email }}
</span>
</a-tag>
</a-select-option>
<template #tagRender="{ label, value: val, onClose }">
<a-tag
v-if="options.find((el) => el.id === val)"
class="rounded-tag nc-selected-option"
:style="{ display: 'flex', alignItems: 'center' }"
color="'#ccc'"
:closable="editAllowed && ((vModel?.length ?? 0) > 1 || !column?.rqd)"
:close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })"
@click="onTagClick($event, onClose)"
@close="onClose"
>
<span
:style="{
'color': tinycolor.isReadable('#ccc' || '#ccc', '#fff', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor.mostReadable('#ccc' || '#ccc', ['#0b1d05', '#fff']).toHex8String(),
'font-size': '13px',
}"
:class="{ 'text-sm': isKanban }"
>
{{ label }}
</span>
</a-tag>
</template>
</a-select>
</div>
</template>
<style scoped lang="scss">
.ms-close-icon {
color: rgba(0, 0, 0, 0.25);
cursor: pointer;
display: flex;
font-size: 12px;
font-style: normal;
height: 12px;
line-height: 1;
text-align: center;
text-transform: none;
transition: color 0.3s ease, opacity 0.15s ease;
width: 12px;
z-index: 1;
margin-right: -6px;
margin-left: 3px;
}
.ms-close-icon:before {
display: block;
}
.ms-close-icon:hover {
color: rgba(0, 0, 0, 0.45);
}
.read-only {
.ms-close-icon {
display: none;
}
}
.rounded-tag {
@apply py-0 px-[12px] rounded-[12px];
}
:deep(.ant-tag) {
@apply "rounded-tag" my-[2px];
}
:deep(.ant-tag-close-icon) {
@apply "text-slate-500";
}
:deep(.ant-select-selection-overflow-item) {
@apply "flex overflow-hidden";
}
:deep(.ant-select-selection-overflow) {
@apply flex-nowrap overflow-hidden;
}
.nc-multi-select:not(.read-only) {
:deep(.ant-select-selector),
:deep(.ant-select-selector input) {
@apply "!cursor-pointer";
}
}
:deep(.ant-select-selector) {
@apply !px-0;
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
}
</style>

2
packages/nc-gui/components/smartsheet/Cell.vue

@ -40,6 +40,7 @@ import {
isTextArea,
isTime,
isURL,
isUser,
isYear,
provide,
ref,
@ -245,6 +246,7 @@ onUnmounted(() => {
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" v-model="vModel" />
<LazyCellPercent v-else-if="isPercent(column)" v-model="vModel" />
<LazyCellCurrency v-else-if="isCurrency(column)" v-model="vModel" @save="emit('save')" />
<LazyCellUser v-else-if="isUser(column)" v-model="vModel" />
<LazyCellDecimal v-else-if="isDecimal(column)" v-model="vModel" />
<LazyCellFloat v-else-if="isFloat(column, abstractType)" v-model="vModel" />
<LazyCellText v-else-if="isString(column, abstractType)" v-model="vModel" />

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

@ -323,6 +323,7 @@ if (props.fromTableExplorer) {
<LazySmartsheetColumnLinkOptions v-if="isEdit && formState.uidt === UITypes.Links" v-model:value="formState" />
<LazySmartsheetColumnPercentOptions v-if="formState.uidt === UITypes.Percent" v-model:value="formState" />
<LazySmartsheetColumnSpecificDBTypeOptions v-if="formState.uidt === UITypes.SpecificDBType" />
<LazySmartsheetColumnUserOptions v-if="formState.uidt === UITypes.User" v-model:value="formState" :is-edit="isEdit" />
<SmartsheetColumnSelectOptions
v-if="formState.uidt === UITypes.SingleSelect || formState.uidt === UITypes.MultiSelect"
v-model:value="formState"

53
packages/nc-gui/components/smartsheet/column/UserOptions.vue

@ -0,0 +1,53 @@
<script setup lang="ts">
import { useVModel } from '#imports'
const props = defineProps<{
value: any
isEdit: boolean
}>()
const emit = defineEmits(['update:value'])
const vModel = useVModel(props, 'value', emit)
const future = ref(false)
const initialIsMulti = ref()
const validators = {}
const { setAdditionalValidations } = useColumnCreateStoreOrThrow()
setAdditionalValidations({
...validators,
})
// set default value
vModel.value.meta = {
is_multi: false,
notify: false,
...vModel.value.meta,
}
onMounted(() => {
initialIsMulti.value = vModel.value.meta.is_multi
})
</script>
<template>
<div class="flex flex-col">
<div>
<a-checkbox v-if="vModel.meta" v-model:checked="vModel.meta.is_multi" class="ml-1 mb-1">
<span class="text-[10px] text-gray-600">Allow adding multiple users</span>
</a-checkbox>
</div>
<div v-if="future">
<a-checkbox v-if="vModel.meta" v-model:checked="vModel.meta.notify" class="ml-1 mb-1">
<span class="text-[10px] text-gray-600">Notify users with base access when they're added</span>
</a-checkbox>
</div>
<div v-if="initialIsMulti && isEdit && !vModel.meta.is_multi" class="text-error text-[10px] mb-1 mt-2">
<span>Changing from multiple mode to single will retain only first user in each cell!!!</span>
</div>
</div>
</template>

6
packages/nc-gui/components/smartsheet/header/CellIcon.ts

@ -31,6 +31,7 @@ import {
isTextArea,
isTime,
isURL,
isUser,
isYear,
storeToRefs,
toRef,
@ -82,6 +83,11 @@ const renderIcon = (column: ColumnType, abstractType: any) => {
return iconMap.percent
} else if (isGeometry(column)) {
return iconMap.calculator
} else if (isUser(column)) {
if ((column.meta as { is_multi: boolean; notify: boolean }).is_multi) {
return iconMap.phUsers
}
return iconMap.phUser
} else if (isInt(column, abstractType) || isFloat(column, abstractType)) {
return iconMap.number
} else if (isString(column, abstractType)) {

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

@ -126,6 +126,7 @@ type NcProject = BaseType & {
edit?: boolean
starred?: boolean
uuid?: string
users?: User[]
}
interface UndoRedoAction {

1
packages/nc-gui/utils/cell.ts

@ -32,6 +32,7 @@ export const isGeoData = (column: ColumnType) => column.uidt === UITypes.GeoData
export const isPercent = (column: ColumnType) => column.uidt === UITypes.Percent
export const isSpecificDBType = (column: ColumnType) => column.uidt === UITypes.SpecificDBType
export const isGeometry = (column: ColumnType) => column.uidt === UITypes.Geometry
export const isUser = (column: ColumnType) => column.uidt === UITypes.User
export const isAutoSaved = (column: ColumnType) =>
[
UITypes.SingleLineText,

4
packages/nc-gui/utils/columnUtils.ts

@ -134,6 +134,10 @@ const uiTypes = [
name: UITypes.SpecificDBType,
icon: iconMap.specificDbType,
},
{
name: UITypes.User,
icon: iconMap.account,
},
]
const getUIDTIcon = (uidt: UITypes | string) => {

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

@ -91,6 +91,9 @@ import Project from '~icons/nc-icons/project'
import LookupIcon from '~icons/nc-icons/lookup'
import FileImageIcon from '~icons/nc-icons/file-image'
import PhUsers from '~icons/ph/users'
import PhUser from '~icons/ph/user'
// Roles
import SuperAdmin from '~icons/nc-icons/super-admin'
import Owner from '~icons/nc-icons/owner'
@ -320,6 +323,8 @@ export const iconMap = {
lock: h('span', { class: 'material-symbols' }, 'lock'),
account: h('span', { class: 'material-symbols' }, 'person'),
accountCircle: h('span', { class: 'material-symbols' }, 'account_circle'),
phUser: PhUser,
phUsers: PhUsers,
users: NcUsers,
cloudDownload: h('span', { class: 'material-symbols' }, 'cloud_download'),
download: MsDownloadRounded,

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

@ -39,6 +39,7 @@ enum UITypes {
QrCode = 'QrCode',
Button = 'Button',
Links = 'Links',
User = 'User',
}
export const numericUITypes = [

178
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -51,6 +51,7 @@ import {
PresignedUrl,
Sort,
Source,
User,
View,
} from '~/models';
import { sanitize, unsanitize } from '~/helpers/sqlSanitize';
@ -2304,7 +2305,7 @@ class BaseModelSqlv2 {
await this.beforeInsert(insertObj, trx, cookie);
}
await this.prepareAttachmentData(insertObj);
await this.prepareNocoData(insertObj);
let response;
// const driver = trx ? trx : this.dbDriver;
@ -2547,7 +2548,7 @@ class BaseModelSqlv2 {
await this.beforeUpdate(data, trx, cookie);
await this.prepareAttachmentData(updateObj);
await this.prepareNocoData(updateObj);
const prevData = await this.readByPk(
id,
@ -3015,7 +3016,7 @@ class BaseModelSqlv2 {
}
}
await this.prepareAttachmentData(insertObj);
await this.prepareNocoData(insertObj);
// prepare nested link data for insert only if it is single record insertion
if (isSingleRecordInsertion) {
@ -3153,7 +3154,7 @@ class BaseModelSqlv2 {
continue;
}
if (!raw) {
await this.prepareAttachmentData(d);
await this.prepareNocoData(d);
const oldRecord = await this.readByPk(pkValues);
if (!oldRecord) {
@ -4376,12 +4377,14 @@ class BaseModelSqlv2 {
skipDateConversion?: boolean;
skipAttachmentConversion?: boolean;
skipSubstitutingColumnIds?: boolean;
skipUserConversion?: boolean;
raw?: boolean; // alias for skipDateConversion and skipAttachmentConversion
first?: boolean;
} = {
skipDateConversion: false,
skipAttachmentConversion: false,
skipSubstitutingColumnIds: false,
skipUserConversion: false,
raw: false,
first: false,
},
@ -4390,6 +4393,7 @@ class BaseModelSqlv2 {
options.skipDateConversion = true;
options.skipAttachmentConversion = true;
options.skipSubstitutingColumnIds = true;
options.skipUserConversion = true;
}
if (options.first && typeof qb !== 'string') {
@ -4422,6 +4426,11 @@ class BaseModelSqlv2 {
data = this.convertDateFormat(data, childTable);
}
// update user fields
if (!options.skipUserConversion) {
data = await this.convertUserFormat(data, childTable);
}
if (!options.skipSubstitutingColumnIds) {
data = await this.substituteColumnIdsWithColumnTitles(data, childTable);
}
@ -4515,6 +4524,102 @@ class BaseModelSqlv2 {
return data;
}
protected async convertUserFormat(
data: Record<string, any>,
childTable?: Model,
) {
// user is stored as id within the database
// convertUserFormat is used to convert the response in id to user object in API response
if (data) {
if (childTable && !childTable?.columns) {
await childTable.getColumns();
} else if (!this.model?.columns) {
await this.model.getColumns();
}
const userColumns = [];
const columns = childTable ? childTable.columns : this.model.columns;
for (const col of columns) {
if (col.uidt === UITypes.Lookup) {
if ((await this.getNestedUidt(col)) === UITypes.User) {
userColumns.push(col);
}
} else {
if (col.uidt === UITypes.User) {
userColumns.push(col);
}
}
}
if (userColumns.length) {
if (Array.isArray(data)) {
data = await Promise.all(
data.map((d) => this._convertUserFormat(userColumns, d)),
);
} else {
data = await this._convertUserFormat(userColumns, data);
}
}
}
return data;
}
protected async _convertUserFormat(
userColumns: Record<string, any>[],
d: Record<string, any>,
) {
try {
if (d) {
const promises = [];
for (const col of userColumns) {
// we expect array of string of comma separated user ids in case of lookup
if (Array.isArray(d[col.id])) {
} else {
if (d[col.id] && d[col.id].length) {
d[col.id] = d[col.id].split(',');
} else {
d[col.id] = [];
}
if (d[col.id]?.length) {
promises.push(
new Promise((resolve) => {
const users = [];
for (const userId of d[col.id]) {
users.push(
User.get(userId)
.then((user) => {
const { id, email, display_name } = user;
return {
id,
email,
display_name,
};
})
.catch((e) => {
console.log(e);
return null;
}),
);
}
Promise.all(users).then((users) => {
d[col.id] = users;
resolve(true);
});
}),
);
}
}
}
await Promise.all(promises);
}
} catch {}
return d;
}
protected async _convertAttachmentType(
attachmentColumns: Record<string, any>[],
d: Record<string, any>,
@ -5353,8 +5458,12 @@ class BaseModelSqlv2 {
}
}
prepareAttachmentData(data) {
if (this.model.columns.some((c) => c.uidt === UITypes.Attachment)) {
async prepareNocoData(data) {
if (
this.model.columns.some((c) =>
[UITypes.Attachment, UITypes.User].includes(c.uidt),
)
) {
for (const column of this.model.columns) {
if (column.uidt === UITypes.Attachment) {
if (data[column.column_name]) {
@ -5371,6 +5480,63 @@ class BaseModelSqlv2 {
}
}
}
} else if (column.uidt === UITypes.User) {
if (data[column.column_name]) {
const userIds = [];
if (typeof data[column.column_name] === 'string') {
const users = data[column.column_name].split(',');
for (const user of users) {
try {
if (user.includes('@')) {
const u = await User.getByEmail(user);
if (!u) {
throw new Error(`User with email '${user}' not found`);
}
userIds.push(u.id);
} else {
const u = await User.get(user);
if (!u) {
throw new Error(`User with id '${user}' not found`);
}
userIds.push(u.id);
}
} catch (e) {
NcError.unprocessableEntity(e.message);
}
}
} else {
const users: { id?: string; email?: string }[] = Array.isArray(
data[column.column_name],
)
? data[column.column_name]
: [data[column.column_name]];
for (const userObj of users) {
const user = extractProps(userObj, ['id', 'email']);
try {
if (user.id) {
const u = await User.get(user.id);
if (!u) {
throw new Error(`User with id '${user.id}' not found`);
}
userIds.push(u.id);
} else if (user.email) {
const u = await User.getByEmail(user.email);
if (!u) {
throw new Error(
`User with email '${user.email}' not found`,
);
}
userIds.push(u.id);
} else {
throw new Error('Invalid user object');
}
} catch (e) {
NcError.unprocessableEntity(e.message);
}
}
}
data[column.column_name] = userIds.join(',');
}
}
}
}

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

@ -12126,7 +12126,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string"
},
@ -15343,7 +15344,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string",
"description": "UI Data Type"

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

@ -17343,7 +17343,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string"
},
@ -20563,7 +20564,8 @@
"URL",
"Year",
"QrCode",
"Links"
"Links",
"User"
],
"type": "string",
"description": "UI Data Type"

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

@ -952,6 +952,134 @@ export class ColumnsService {
await Column.update(param.columnId, {
...colBody,
});
} else if (colBody.uidt === UITypes.User) {
if (column.uidt === UITypes.User) {
// multi user to single user
if (
colBody.meta?.is_multi === false &&
column.meta?.is_multi === true
) {
const baseModel = await reuseOrSave('baseModel', reuse, async () =>
Model.getBaseModelSQL({
id: table.id,
dbDriver: await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
),
}),
);
const dbDriver = await reuseOrSave('dbDriver', reuse, async () =>
NcConnectionMgrv2.get(source),
);
const driverType = dbDriver.clientType();
// MultiSelect to SingleSelect
if (driverType === 'mysql' || driverType === 'mysql2') {
await sqlClient.raw(
`UPDATE ?? SET ?? = SUBSTRING_INDEX(??, ',', 1) WHERE ?? LIKE '%,%';`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
],
);
} else if (driverType === 'pg') {
await sqlClient.raw(`UPDATE ?? SET ?? = split_part(??, ',', 1);`, [
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
]);
} else if (driverType === 'mssql') {
await sqlClient.raw(
`UPDATE ?? SET ?? = LEFT(cast(?? as varchar(max)), CHARINDEX(',', ??) - 1) WHERE CHARINDEX(',', ??) > 0;`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
} else if (driverType === 'sqlite3') {
await sqlClient.raw(
`UPDATE ?? SET ?? = substr(??, 1, instr(??, ',') - 1) WHERE ?? LIKE '%,%';`,
[
baseModel.getTnPath(table.table_name),
column.column_name,
column.column_name,
column.column_name,
column.column_name,
],
);
}
}
colBody = await getColumnPropsFromUIDT(colBody, source);
const tableUpdateBody = {
...table,
tn: table.table_name,
originalColumns: table.columns.map((c) => ({
...c,
cn: c.column_name,
cno: c.column_name,
})),
columns: await Promise.all(
table.columns.map(async (c) => {
if (c.id === param.columnId) {
const res = {
...c,
...colBody,
cn: colBody.column_name,
cno: c.column_name,
altered: Altered.UPDATE_COLUMN,
};
// update formula with new column name
if (c.column_name != colBody.column_name) {
const formulas = await Noco.ncMeta
.knex(MetaTable.COL_FORMULA)
.where('formula', 'like', `%${c.id}%`);
if (formulas) {
const new_column = c;
new_column.column_name = colBody.column_name;
new_column.title = colBody.title;
for (const f of formulas) {
// the formula with column IDs only
const formula = f.formula;
// replace column IDs with alias to get the new formula_raw
const new_formula_raw =
substituteColumnIdWithAliasInFormula(formula, [
new_column,
]);
await FormulaColumn.update(c.id, {
formula_raw: new_formula_raw,
});
}
}
}
return Promise.resolve(res);
} else {
(c as any).cn = c.column_name;
}
return Promise.resolve(c);
}),
),
};
const sqlMgr = await reuseOrSave('sqlMgr', reuse, async () =>
ProjectMgrv2.getSqlMgr({ id: source.base_id }),
);
await sqlMgr.sqlOpPlus(source, 'tableUpdate', tableUpdateBody);
await Column.update(param.columnId, {
...colBody,
});
} else {
NcError.notImplemented(
`Updating ${column.uidt} => ${colBody.uidt} is not supported at the moment`,
);
}
} else {
colBody = await getColumnPropsFromUIDT(colBody, source);
const tableUpdateBody = {

Loading…
Cancel
Save