mirror of https://github.com/nocodb/nocodb
129 changed files with 6677 additions and 2305 deletions
@ -0,0 +1,435 @@
|
||||
<script lang="ts" setup> |
||||
import { onUnmounted } from '@vue/runtime-core' |
||||
import tinycolor from 'tinycolor2' |
||||
import type { Select as AntSelect } from 'ant-design-vue' |
||||
import type { UserFieldRecordType } from 'nocodb-sdk' |
||||
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?: UserFieldRecordType[] | string | null |
||||
rowIndex?: number |
||||
location?: 'cell' | 'filter' |
||||
forceMulti?: boolean |
||||
} |
||||
|
||||
const { modelValue, forceMulti } = defineProps<Props>() |
||||
|
||||
const emit = defineEmits(['update:modelValue']) |
||||
|
||||
const { isMobileMode } = useGlobal() |
||||
|
||||
const meta = inject(MetaInj)! |
||||
|
||||
const column = inject(ColumnInj)! |
||||
|
||||
const readOnly = inject(ReadonlyInj)! |
||||
|
||||
const isEditable = inject(EditModeInj, ref(false)) |
||||
|
||||
const activeCell = inject(ActiveCellInj, ref(false)) |
||||
|
||||
const basesStore = useBases() |
||||
|
||||
const { basesUser } = storeToRefs(basesStore) |
||||
|
||||
const baseUsers = computed(() => (meta.value.base_id ? basesUser.value.get(meta.value.base_id) || [] : [])) |
||||
|
||||
// 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(() => forceMulti || (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<UserFieldRecordType[]>(() => { |
||||
const collaborators: UserFieldRecordType[] = [] |
||||
|
||||
collaborators.push( |
||||
...(baseUsers.value?.map((user: any) => ({ |
||||
id: user.id, |
||||
email: user.email, |
||||
display_name: user.display_name, |
||||
deleted: user.deleted, |
||||
})) || []), |
||||
) |
||||
return collaborators |
||||
}) |
||||
|
||||
const hasEditRoles = computed(() => isUIAllowed('dataEdit')) |
||||
|
||||
const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && active.value) |
||||
|
||||
const vModel = computed({ |
||||
get: () => { |
||||
let selected: { label: string; value: string }[] = [] |
||||
if (typeof modelValue === 'string') { |
||||
const idsOrMails = modelValue.split(',').map((idOrMail) => idOrMail.trim()) |
||||
selected = idsOrMails.reduce((acc, idOrMail) => { |
||||
const user = options.value.find((u) => u.id === idOrMail || u.email === idOrMail) |
||||
if (user) { |
||||
acc.push({ |
||||
label: user?.display_name || user?.email, |
||||
value: user.id, |
||||
}) |
||||
} |
||||
return acc |
||||
}, [] as { label: string; value: string }[]) |
||||
} else { |
||||
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: 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.id) |
||||
} |
||||
}) |
||||
} |
||||
if (isMultiple.value) { |
||||
emit('update:modelValue', val?.length ? value.join(',') : 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() |
||||
} |
||||
} |
||||
}) |
||||
|
||||
// set isOpen to false when active cell is changed |
||||
watch(active, (n, _o) => { |
||||
if (!n) isOpen.value = false |
||||
}) |
||||
|
||||
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-user-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 opt = options.value.find((o) => o.id === option.value) |
||||
const searchVal = opt?.display_name || opt?.email |
||||
if (searchVal) { |
||||
return searchVal.toLowerCase().includes(input.toLowerCase()) |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="nc-user-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-user-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> |
||||
<template v-for="op of options" :key="op.id || op.email"> |
||||
<a-select-option |
||||
v-if="!op.deleted" |
||||
: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> |
||||
|
||||
<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 bg-gray-200 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-user-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> |
@ -0,0 +1,66 @@
|
||||
<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 |
||||
}) |
||||
|
||||
const updateIsMulti = (e) => { |
||||
vModel.value.meta.is_multi = e.target.checked |
||||
if (!vModel.value.meta.is_multi) { |
||||
vModel.value.cdf = vModel.value.cdf?.split(',')[0] || null |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<template> |
||||
<div class="flex flex-col"> |
||||
<div> |
||||
<a-checkbox |
||||
v-if="vModel.meta" |
||||
:checked="vModel.meta.is_multi" |
||||
class="ml-1 mb-1" |
||||
data-testid="user-column-allow-multiple" |
||||
@change="updateIsMulti" |
||||
> |
||||
<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> |
@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */ |
||||
module.exports = { |
||||
preset: 'ts-jest', |
||||
testEnvironment: 'node', |
||||
}; |
@ -0,0 +1,77 @@
|
||||
import { |
||||
FormulaDataTypes, |
||||
validateFormulaAndExtractTreeWithType, |
||||
} from './formulaHelpers'; |
||||
import UITypes from './UITypes'; |
||||
|
||||
describe('Formula parsing and type validation', () => { |
||||
it('Simple formula', async () => { |
||||
const result = await validateFormulaAndExtractTreeWithType({ |
||||
formula: '1 + 2', |
||||
columns: [], |
||||
clientOrSqlUi: 'mysql2', |
||||
getMeta: async () => ({}), |
||||
}); |
||||
|
||||
expect(result.dataType).toEqual(FormulaDataTypes.NUMERIC); |
||||
}); |
||||
|
||||
it('Formula with IF condition', async () => { |
||||
const result = await validateFormulaAndExtractTreeWithType({ |
||||
formula: 'IF({column}, "Found", BLANK())', |
||||
columns: [ |
||||
{ |
||||
id: 'cid', |
||||
title: 'column', |
||||
uidt: UITypes.Number, |
||||
}, |
||||
], |
||||
clientOrSqlUi: 'mysql2', |
||||
getMeta: async () => ({}), |
||||
}); |
||||
|
||||
expect(result.dataType).toEqual(FormulaDataTypes.STRING); |
||||
}); |
||||
it('Complex formula', async () => { |
||||
const result = await validateFormulaAndExtractTreeWithType({ |
||||
formula: |
||||
'SWITCH({column2},"value1",IF({column1}, "Found", BLANK()),"value2", 2)', |
||||
columns: [ |
||||
{ |
||||
id: 'id1', |
||||
title: 'column1', |
||||
uidt: UITypes.Number, |
||||
}, |
||||
{ |
||||
id: 'id2', |
||||
title: 'column2', |
||||
uidt: UITypes.SingleLineText, |
||||
}, |
||||
], |
||||
clientOrSqlUi: 'mysql2', |
||||
getMeta: async () => ({}), |
||||
}); |
||||
|
||||
expect(result.dataType).toEqual(FormulaDataTypes.STRING); |
||||
|
||||
const result1 = await validateFormulaAndExtractTreeWithType({ |
||||
formula: 'SWITCH({column2},"value1",IF({column1}, 1, 2),"value2", 2)', |
||||
columns: [ |
||||
{ |
||||
id: 'id1', |
||||
title: 'column1', |
||||
uidt: UITypes.Number, |
||||
}, |
||||
{ |
||||
id: 'id2', |
||||
title: 'column2', |
||||
uidt: UITypes.SingleLineText, |
||||
}, |
||||
], |
||||
clientOrSqlUi: 'mysql2', |
||||
getMeta: async () => ({}), |
||||
}); |
||||
|
||||
expect(result1.dataType).toEqual(FormulaDataTypes.NUMERIC); |
||||
}); |
||||
}); |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
||||
import type { Knex } from 'knex'; |
||||
import { MetaTable } from '~/utils/globals'; |
||||
|
||||
const up = async (knex: Knex) => { |
||||
await knex.schema.alterTable(MetaTable.COL_FORMULA, (table) => { |
||||
table.text('parsed_tree'); |
||||
}); |
||||
}; |
||||
|
||||
const down = async (knex: Knex) => { |
||||
await knex.schema.alterTable(MetaTable.COL_FORMULA, (table) => { |
||||
table.dropColumn('parsed_tree'); |
||||
}); |
||||
}; |
||||
|
||||
export { up, down }; |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue