Browse Source

feat: formula formatting support (#9048)

* feat: numeric formula formatting support

* feat: support url, email and phone feat: add datetime supports feat: add checkbox support

* fix: clean up

* fix: handle invalid source, fix: handle plain cell chore: translations

* fix: update the datatype when formula changes

* fix: formula fixes

* fix: tab ui

* fix: pr review changes
pull/9067/head
Anbarasu 4 months ago committed by GitHub
parent
commit
b9a15f2c51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 82
      packages/nc-gui/components/smartsheet/FormulaWrapperCell.vue
  2. 7
      packages/nc-gui/components/smartsheet/PlainCell.vue
  3. 377
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  4. 11
      packages/nc-gui/components/virtual-cell/Formula.vue
  5. 3
      packages/nc-gui/lang/en.json
  6. 23
      packages/nocodb-sdk/src/lib/UITypes.ts
  7. 3
      packages/nocodb-sdk/src/lib/index.ts

82
packages/nc-gui/components/smartsheet/FormulaWrapperCell.vue

@ -0,0 +1,82 @@
<script setup lang="ts">
interface Props {
modelValue: any
column?: any
}
const props = defineProps<Props>()
const column = toRef(props, 'column')
const isGrid = inject(IsGridInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const isNumericField = computed(() => {
return isNumericFieldType(column.value, null)
})
provide(ReadonlyInj, ref(true))
provide(EditModeInj, ref(false))
provide(ColumnInj, column)
</script>
<template>
<div
class="nc-cell w-full h-full relative"
:class="{
'nc-grid-numeric-cell-right': isGrid && isNumericField && !isExpandedFormOpen && !isRating(column),
}"
>
<LazyCellCheckbox v-if="isBoolean(column)" :model-value="modelValue" />
<LazyCellCurrency v-else-if="isCurrency(column)" :model-value="modelValue" />
<LazyCellDecimal v-else-if="isDecimal(column)" :model-value="modelValue" />
<LazyCellPercent v-else-if="isPercent(column)" :model-value="modelValue" />
<LazyCellRating v-else-if="isRating(column)" :model-value="modelValue" />
<LazyCellDatePicker v-else-if="isDate(column, '')" :model-value="modelValue" />
<LazyCellDateTimePicker v-else-if="isDateTime(column, '')" :model-value="modelValue" />
<LazyCellTimePicker v-else-if="isTime(column, '')" :model-value="modelValue" />
<LazyCellEmail v-else-if="isEmail(column)" :model-value="modelValue" />
<LazyCellUrl v-else-if="isURL(column)" :model-value="modelValue" />
<LazyCellPhoneNumber v-else-if="isPhoneNumber(column)" :model-value="modelValue" />
<LazyCellText v-else :model-value="modelValue" />
</div>
</template>
<style scoped lang="scss">
.nc-grid-numeric-cell-left {
text-align: left;
:deep(input) {
text-align: left;
}
}
.nc-grid-numeric-cell-right {
text-align: right;
:deep(input) {
text-align: right;
}
}
.nc-cell {
@apply text-sm text-gray-600;
font-weight: 500;
:deep(.nc-cell-field) {
@apply !text-sm;
font-weight: 500;
}
&.nc-display-value-cell {
@apply !text-brand-500 !font-semibold;
:deep(.nc-cell-field) {
@apply !font-semibold;
}
}
:deep(.nc-cell-field) {
@apply px-0;
}
}
</style>

7
packages/nc-gui/components/smartsheet/PlainCell.vue

@ -339,6 +339,13 @@ const parseValue = (value: any, col: ColumnType): string => {
return getLinksValue(value, col)
}
if (isFormula(col) && col?.meta?.display_type) {
return parseValue(value, {
uidt: col?.meta?.display_type,
...col?.meta?.display_column_meta,
})
}
return value as unknown as string
}
</script>

377
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -3,9 +3,12 @@ import type { Ref } from 'vue'
import type { ListItem as AntListItem } from 'ant-design-vue'
import jsep from 'jsep'
import {
FormulaDataTypes,
FormulaError,
UITypes,
getUITypesForFormulaDataType,
isHiddenCol,
isVirtualCol,
jsepCurlyHook,
substituteColumnIdWithAliasInFormula,
validateFormulaAndExtractTreeWithType,
@ -30,6 +33,8 @@ const { predictFunction: _predictFunction } = useNocoEe()
const meta = inject(MetaInj, ref())
const { base: activeBase } = storeToRefs(useBase())
const supportedColumns = computed(
() =>
meta?.value?.columns?.filter((col) => {
@ -279,6 +284,25 @@ if ((column.value?.colOptions as any)?.formula_raw) {
) || ''
}
const source = computed(() => activeBase.value?.sources?.find((s) => s.id === meta.value?.source_id))
const parsedTree = computedAsync(async () => {
try {
const parsed = await validateFormulaAndExtractTreeWithType({
formula: vModel.value.formula || vModel.value.formula_raw,
columns: meta.value?.columns || [],
column: column.value ?? undefined,
clientOrSqlUi: source.value?.type as any,
getMeta: async (modelId) => await getMeta(modelId),
})
return parsed
} catch (e) {
return {
dataType: FormulaDataTypes.UNKNOWN,
}
}
})
// set additional validations
setAdditionalValidations({
...validators,
@ -332,12 +356,65 @@ const handleKeydown = (e: KeyboardEvent) => {
}
}
}
const activeKey = ref('formula')
const supportedFormulaAlias = computed(() => {
if (!parsedTree.value?.dataType) return []
try {
return getUITypesForFormulaDataType(parsedTree.value?.dataType as FormulaDataTypes).map((uidt) => {
return {
value: uidt,
label: t(`datatype.${uidt}`),
icon: h(
isVirtualCol(uidt) ? resolveComponent('SmartsheetHeaderVirtualCellIcon') : resolveComponent('SmartsheetHeaderCellIcon'),
{
columnMeta: {
uidt,
},
},
),
}
})
} catch (e) {
return []
}
})
watch(
() => vModel.value.meta?.display_type,
(value, oldValue) => {
if (oldValue === undefined && !value) {
vModel.value.meta.display_column_meta = {
meta: {},
custom: {},
}
}
},
{
immediate: true,
},
)
watch(parsedTree, (value, oldValue) => {
if (oldValue === undefined && value) {
return
}
if (value?.dataType !== oldValue?.dataType) {
vModel.value.meta.display_type = null
}
})
</script>
<template>
<div class="formula-wrapper relative">
<div
v-if="suggestionPreviewed && !suggestionPreviewed.unsupported && suggestionPreviewed.type === 'function'"
v-if="
suggestionPreviewed &&
!suggestionPreviewed.unsupported &&
suggestionPreviewed.type === 'function' &&
activeKey === 'formula'
"
class="w-84 fixed bg-white z-10 pl-3 pt-3 border-1 shadow-md rounded-xl"
:style="{
left: suggestionPreviewPostion.left,
@ -384,109 +461,191 @@ const handleKeydown = (e: KeyboardEvent) => {
</a>
</div>
</div>
<a-form-item v-bind="validateInfos.formula_raw" :label="$t('datatype.Formula')">
<!-- <GeneralIcon
v-if="isEeUI"
icon="magic"
:class="{ 'nc-animation-pulse': loadMagic }"
class="text-orange-400 cursor-pointer absolute right-1 top-1 z-10"
@click="predictFunction()"
/> -->
<a-textarea
ref="formulaRef"
v-model:value="vModel.formula_raw"
class="nc-formula-input !rounded-md"
@keydown="handleKeydown"
@change="handleInputDeb"
/>
</a-form-item>
<div class="h-[250px] overflow-auto nc-scrollbar-thin border-1 border-gray-200 rounded-lg mt-4">
<template v-if="suggestedFormulas && showFunctionList">
<div class="border-b-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs font-semibold sticky top-0 z-10">
Formulas
</div>
<a-list :data-source="suggestedFormulas" :locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }">
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index] = el
}
"
class="cursor-pointer !overflow-hidden hover:bg-gray-50"
:class="{
'!bg-gray-100': selected === index,
'cursor-not-allowed': item.unsupported,
}"
@click.prevent.stop="!item.unsupported && appendText(item)"
@mouseenter="suggestionPreviewed = item"
>
<a-list-item-meta>
<template #title>
<div class="flex items-center gap-x-1" :class="{ 'text-gray-400': item.unsupported }">
<component :is="iconMap.function" v-if="item.type === 'function'" class="w-4 h-4 !text-gray-600" />
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="w-4 h-4 !text-gray-600" />
<component :is="item.icon" v-if="item.type === 'column'" class="w-4 h-4 !text-gray-600" />
<span class="text-small leading-[18px]" :class="{ 'text-gray-800': !item.unsupported }">{{ item.text }}</span>
</div>
<div v-if="item.unsupported" class="ml-5 text-gray-400 text-xs">{{ $t('msg.formulaNotSupported') }}</div>
<NcTabs v-model:activeKey="activeKey">
<a-tab-pane key="formula">
<template #tab>
<div class="tab">
<div>{{ $t('datatype.Formula') }}</div>
</div>
</template>
<div class="px-0.5">
<a-form-item class="mt-4" v-bind="validateInfos.formula_raw">
<a-textarea
ref="formulaRef"
v-model:value="vModel.formula_raw"
class="nc-formula-input !rounded-md"
@keydown="handleKeydown"
@change="handleInputDeb"
/>
</a-form-item>
<div class="h-[250px] overflow-auto nc-scrollbar-thin border-1 border-gray-200 rounded-lg mt-4">
<template v-if="suggestedFormulas && showFunctionList">
<div class="border-b-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs font-semibold sticky top-0 z-10">
Formulas
</div>
<a-list :data-source="suggestedFormulas" :locale="{ emptyText: $t('msg.formula.noSuggestedFormulaFound') }">
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index] = el
}
"
class="cursor-pointer !overflow-hidden hover:bg-gray-50"
:class="{
'!bg-gray-100': selected === index,
'cursor-not-allowed': item.unsupported,
}"
@click.prevent.stop="!item.unsupported && appendText(item)"
@mouseenter="suggestionPreviewed = item"
>
<a-list-item-meta>
<template #title>
<div class="flex items-center gap-x-1" :class="{ 'text-gray-400': item.unsupported }">
<component :is="iconMap.function" v-if="item.type === 'function'" class="w-4 h-4 !text-gray-600" />
<component :is="iconMap.calculator" v-if="item.type === 'op'" class="w-4 h-4 !text-gray-600" />
<component :is="item.icon" v-if="item.type === 'column'" class="w-4 h-4 !text-gray-600" />
<span class="text-small leading-[18px]" :class="{ 'text-gray-800': !item.unsupported }">{{
item.text
}}</span>
</div>
<div v-if="item.unsupported" class="ml-5 text-gray-400 text-xs">{{ $t('msg.formulaNotSupported') }}</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
<template v-if="variableList">
<div class="border-b-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs font-semibold sticky top-0 z-10">Fields</div>
</a-list>
</template>
<template v-if="variableList">
<div class="border-b-1 bg-gray-50 px-3 py-1 uppercase text-gray-600 text-xs font-semibold sticky top-0 z-10">
Fields
</div>
<a-list
ref="variableListRef"
:data-source="variableList"
:locale="{ emptyText: $t('msg.formula.noSuggestedFieldFound') }"
class="!overflow-hidden"
>
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index + suggestedFormulas.length] = el
}
"
:class="{
'!bg-gray-100': selected === index + suggestedFormulas.length,
}"
class="cursor-pointer hover:bg-gray-50"
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta class="nc-variable-list-item">
<template #title>
<div class="flex items-center gap-x-1 justify-between">
<div class="flex items-center gap-x-1 rounded-md bg-gray-200 px-1 h-5">
<component :is="item.icon" class="w-4 h-4 !text-gray-600" />
<span class="text-small leading-[18px] text-gray-800 font-weight-500">{{ item.text }}</span>
</div>
<NcButton
size="small"
type="text"
class="nc-variable-list-item-use-field-btn !h-7 px-3 !text-small invisible"
>
{{ $t('general.use') }} {{ $t('objects.field').toLowerCase() }}
</NcButton>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</template>
</div>
</div>
</a-tab-pane>
<a-list
ref="variableListRef"
:data-source="variableList"
:locale="{ emptyText: $t('msg.formula.noSuggestedFieldFound') }"
class="!overflow-hidden"
>
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index + suggestedFormulas.length] = el
}
"
:class="{
'!bg-gray-100': selected === index + suggestedFormulas.length,
}"
class="cursor-pointer hover:bg-gray-50"
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta class="nc-variable-list-item">
<template #title>
<div class="flex items-center gap-x-1 justify-between">
<div class="flex items-center gap-x-1 rounded-md bg-gray-200 px-1 h-5">
<component :is="item.icon" class="w-4 h-4 !text-gray-600" />
<span class="text-small leading-[18px] text-gray-800 font-weight-500">{{ item.text }}</span>
</div>
<NcButton
size="small"
type="text"
class="nc-variable-list-item-use-field-btn !h-7 px-3 !text-small invisible"
>
{{ $t('general.use') }} {{ $t('objects.field').toLowerCase() }}
</NcButton>
<a-tab-pane key="format" :disabled="!supportedFormulaAlias?.length || !parsedTree?.dataType">
<template #tab>
<div class="tab">
<div>{{ $t('labels.formatting') }}</div>
</div>
</template>
<div class="flex flex-col px-0.5 gap-4">
<a-form-item class="mt-4" :label="$t('general.format')">
<a-select v-model:value="vModel.meta.display_type" class="w-full" :placeholder="$t('labels.selectAFormatType')">
<a-select-option v-for="option in supportedFormulaAlias" :key="option.value" :value="option.value">
<div class="flex w-full items-center gap-2 justify-between">
<div class="w-full">
<component :is="option.icon" class="w-4 h-4 !text-gray-600" />
{{ option.label }}
</div>
</template>
</a-list-item-meta>
</a-list-item>
<component
:is="iconMap.check"
v-if="option.value === vModel.meta?.display_type"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</a-select>
</a-form-item>
<template
v-if="
[
FormulaDataTypes.NUMERIC,
FormulaDataTypes.DATE,
FormulaDataTypes.BOOLEAN,
FormulaDataTypes.STRING,
FormulaDataTypes.COND_EXP,
].includes(parsedTree?.dataType)
"
>
<SmartsheetColumnCurrencyOptions
v-if="vModel.meta.display_type === UITypes.Currency"
:value="vModel.meta.display_column_meta"
/>
<SmartsheetColumnDecimalOptions
v-else-if="vModel.meta.display_type === UITypes.Decimal"
:value="vModel.meta.display_column_meta"
/>
<SmartsheetColumnPercentOptions
v-else-if="vModel.meta.display_type === UITypes.Percent"
:value="vModel.meta.display_column_meta"
/>
<SmartsheetColumnRatingOptions
v-else-if="vModel.meta.display_type === UITypes.Rating"
:value="vModel.meta.display_column_meta"
/>
<SmartsheetColumnTimeOptions
v-else-if="vModel.meta.display_type === UITypes.Time"
:value="vModel.meta.display_column_meta"
/>
<SmartsheetColumnDateTimeOptions
v-else-if="vModel.meta.display_type === UITypes.DateTime"
:value="vModel.meta.display_column_meta"
/>
<SmartsheetColumnDateOptions
v-else-if="vModel.meta.display_type === UITypes.Date"
:value="vModel.meta.display_column_meta"
/>
<SmartsheetColumnCheckboxOptions
v-else-if="vModel.meta.display_type === UITypes.Checkbox"
:value="vModel.meta.display_column_meta"
/>
</template>
</a-list>
</template>
</div>
</div>
</a-tab-pane>
</NcTabs>
</div>
</template>
@ -511,4 +670,24 @@ const handleKeydown = (e: KeyboardEvent) => {
@apply visible;
}
}
:deep(.ant-tabs-nav-wrap) {
@apply !pl-0;
}
:deep(.ant-tabs-content-holder) {
@apply mt-4;
}
:deep(.ant-tabs-tab) {
@apply !pb-0 pt-1;
}
:deep(.ant-tabs-nav) {
@apply !mb-0 !pl-0;
}
:deep(.ant-tabs-tab-btn) {
@apply !mb-1;
}
</style>

11
packages/nc-gui/components/virtual-cell/Formula.vue

@ -27,7 +27,16 @@ const isGrid = inject(IsGridInj, ref(false))
</script>
<template>
<div class="w-full" :class="{ 'text-right': isNumber && isGrid && !isExpandedFormOpen }">
<LazySmartsheetFormulaWrapperCell
v-if="column.meta?.display_type"
v-model="cellValue"
:column="{
uidt: column.meta?.display_type,
...column.meta?.display_column_meta,
}"
/>
<div v-else class="w-full" :class="{ 'text-right': isNumber && isGrid && !isExpandedFormOpen }">
<a-tooltip v-if="column && column.colOptions && column.colOptions.error" placement="bottom" class="text-orange-700">
<template #title>
<span class="font-bold">{{ column.colOptions.error }}</span>

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

@ -521,6 +521,9 @@
"looksLikeThisStackIsEmpty": "Looks like this stack does not have any records"
},
"labels": {
"formatting": "Formatting",
"selectAFormatType": "- -Select a formt type (optional)- -",
"formatType": "Format type",
"toUpload": "to upload",
"dragFilesHere": "drag files here",
"browseFiles": "browse files",

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

@ -295,3 +295,26 @@ export const partialUpdateAllowedTypes = [
UITypes.Email,
UITypes.URL,
];
export const getUITypesForFormulaDataType = (
dataType: FormulaDataTypes
): UITypes[] => {
switch (dataType) {
case FormulaDataTypes.NUMERIC:
return [
UITypes.Decimal,
UITypes.Currency,
UITypes.Percent,
UITypes.Rating,
];
case FormulaDataTypes.DATE:
return [UITypes.DateTime, UITypes.Date, UITypes.Time];
case FormulaDataTypes.BOOLEAN:
case FormulaDataTypes.COND_EXP:
return [UITypes.Checkbox];
case FormulaDataTypes.STRING:
return [UITypes.Email, UITypes.URL, UITypes.PhoneNumber];
default:
return [];
}
};

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

@ -20,8 +20,9 @@ export {
isHiddenCol,
getEquivalentUIType,
isSelectTypeCol,
getUITypesForFormulaDataType,
readonlyMetaAllowedTypes,
partialUpdateAllowedTypes
partialUpdateAllowedTypes,
} from '~/lib/UITypes';
export { default as CustomAPI, FileType } from '~/lib/CustomAPI';
export { default as TemplateGenerator } from '~/lib/TemplateGenerator';

Loading…
Cancel
Save