Browse Source

Merge branch 'develop' into feat/gui-v2-form-view

pull/3030/head
Wing-Kam Wong 2 years ago
parent
commit
cf6cc924f4
  1. 6
      packages/nc-gui-v2/app.vue
  2. 1
      packages/nc-gui-v2/components.d.ts
  3. 2
      packages/nc-gui-v2/components/cell/Checkbox.vue
  4. 14
      packages/nc-gui-v2/components/cell/Currency.vue
  5. 9
      packages/nc-gui-v2/components/cell/DatePicker.vue
  6. 8
      packages/nc-gui-v2/components/cell/DateTimePicker.vue
  7. 12
      packages/nc-gui-v2/components/cell/Decimal.vue
  8. 4
      packages/nc-gui-v2/components/cell/Duration.vue
  9. 2
      packages/nc-gui-v2/components/cell/Email.vue
  10. 3
      packages/nc-gui-v2/components/cell/Float.vue
  11. 1
      packages/nc-gui-v2/components/cell/Integer.vue
  12. 123
      packages/nc-gui-v2/components/cell/Json.vue
  13. 119
      packages/nc-gui-v2/components/cell/JsonEditableCell.vue
  14. 4
      packages/nc-gui-v2/components/cell/MultiSelect.vue
  15. 6
      packages/nc-gui-v2/components/cell/Percent.vue
  16. 4
      packages/nc-gui-v2/components/cell/Rating.vue
  17. 4
      packages/nc-gui-v2/components/cell/SingleSelect.vue
  18. 8
      packages/nc-gui-v2/components/cell/TimePicker.vue
  19. 6
      packages/nc-gui-v2/components/cell/Url.vue
  20. 8
      packages/nc-gui-v2/components/cell/YearPicker.vue
  21. 2
      packages/nc-gui-v2/components/cell/attachment/utils.ts
  22. 72
      packages/nc-gui-v2/components/monaco/Editor.vue
  23. 9
      packages/nc-gui-v2/components/smartsheet-column/AdvancedOptions.vue
  24. 80
      packages/nc-gui-v2/components/smartsheet-column/EditOrAdd.vue
  25. 683
      packages/nc-gui-v2/components/smartsheet-column/FormulaOptions.vue
  26. 2
      packages/nc-gui-v2/components/smartsheet-header/Cell.vue
  27. 9
      packages/nc-gui-v2/components/smartsheet-header/CellIcon.vue
  28. 20
      packages/nc-gui-v2/components/smartsheet-header/Menu.vue
  29. 211
      packages/nc-gui-v2/components/smartsheet-header/VirtualCell.vue
  30. 10
      packages/nc-gui-v2/components/smartsheet-header/VirtualCellIcon.vue
  31. 2
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilter.vue
  32. 31
      packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue
  33. 13
      packages/nc-gui-v2/components/smartsheet/Cell.vue
  34. 98
      packages/nc-gui-v2/components/smartsheet/Grid.vue
  35. 8
      packages/nc-gui-v2/components/smartsheet/Toolbar.vue
  36. 4
      packages/nc-gui-v2/components/smartsheet/VirtualCell.vue
  37. 8
      packages/nc-gui-v2/components/tabs/Smartsheet.vue
  38. 5
      packages/nc-gui-v2/components/virtual-cell/BelongsTo.vue
  39. 7
      packages/nc-gui-v2/components/virtual-cell/HasMany.vue
  40. 5
      packages/nc-gui-v2/components/virtual-cell/ManyToMany.vue
  41. 11
      packages/nc-gui-v2/composables/useColumn.ts
  42. 351
      packages/nc-gui-v2/composables/useColumnCreateStore.ts
  43. 3
      packages/nc-gui-v2/composables/useGlobal/state.ts
  44. 1
      packages/nc-gui-v2/composables/useGlobal/types.ts
  45. 18
      packages/nc-gui-v2/composables/useLTARStore.ts
  46. 15
      packages/nc-gui-v2/composables/useSmartsheetStore.ts
  47. 15
      packages/nc-gui-v2/composables/useTabs.ts
  48. 12
      packages/nc-gui-v2/composables/useViewColumns.ts
  49. 17
      packages/nc-gui-v2/composables/useViewData.ts
  50. 27
      packages/nc-gui-v2/composables/useViewFilters.ts
  51. 22
      packages/nc-gui-v2/composables/useVirtualCell.ts
  52. 2
      packages/nc-gui-v2/context/index.ts
  53. 33
      packages/nc-gui-v2/package-lock.json
  54. 2
      packages/nc-gui-v2/package.json
  55. 3
      packages/nc-gui-v2/pages/nc/[projectId]/index.vue
  56. 4
      packages/nc-gui-v2/pages/nc/[projectId]/index/index.vue
  57. 87
      packages/nc-gui-v2/utils/NcAutocompleteTree.ts
  58. 10
      packages/nc-gui-v2/utils/columnUtils.ts
  59. 14
      packages/nc-gui-v2/utils/dateTimeUtils.ts
  60. 448
      packages/nc-gui-v2/utils/formulaUtils.ts
  61. 3
      packages/nc-gui-v2/utils/index.ts
  62. 5
      scripts/sdk/swagger.json

6
packages/nc-gui-v2/app.vue

@ -30,20 +30,20 @@ const toggleSidebar = () => {
<div class="flex-1" />
<div class="ml-4 flex justify-center flex-1">
<div class="ml-4 flex justify-center shrink">
<div class="flex items-center gap-2 cursor-pointer nc-noco-brand-icon" @click="navigateTo('/')">
<img width="35" src="~/assets/img/icons/512x512-trans.png" />
<span class="prose-xl">NocoDB</span>
</div>
</div>
<div class="flex-1 text-left">
<div v-show="state.isLoading.value" class="flex items-center gap-2 ml-3">
{{ $t('general.loading') }}
<mdi-reload :class="{ 'animate-infinite animate-spin': state.isLoading.value }" />
</div>
</div>
<div class="flex-1" />
<div class="flex justify-end gap-4">
<general-language class="mr-3" />

1
packages/nc-gui-v2/components.d.ts vendored

@ -66,6 +66,7 @@ declare module '@vue/runtime-core' {
MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiReload: typeof import('~icons/mdi/reload')['default']
MdiTableArrowRight: typeof import('~icons/mdi/table-arrow-right')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

2
packages/nc-gui-v2/components/cell/Checkbox.vue

@ -26,7 +26,7 @@ const checkboxMeta = $computed(() => {
unchecked: 'mdi-checkbox-blank-circle-outline',
},
color: 'primary',
...(column?.meta || {}),
...(column?.value?.meta || {}),
}
})
</script>

14
packages/nc-gui-v2/components/cell/Currency.vue

@ -3,7 +3,7 @@ import { computed, inject, ref, useVModel } from '#imports'
import { ColumnInj, EditModeInj } from '~/context'
interface Props {
modelValue: number
modelValue: number | null
}
const props = defineProps<Props>()
@ -22,7 +22,7 @@ const currencyMeta = computed(() => {
return {
currency_locale: 'en-US',
currency_code: 'USD',
...(column && column.meta ? column.meta : {}),
...(column?.value?.meta ? column?.value?.meta : {}),
}
})
const currency = computed(() => {
@ -37,10 +37,18 @@ const currency = computed(() => {
return vModel.value
}
})
const focus = (el: HTMLInputElement) => el?.focus()
</script>
<template>
<input v-if="editEnabled" ref="root" v-model="vModel" />
<input
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="w-full h-full border-none outline-none"
@blur="editEnabled = false"
/>
<span v-else-if="vModel">{{ currency }}</span>
<span v-else />
</template>

9
packages/nc-gui-v2/components/cell/DatePicker.vue

@ -2,19 +2,18 @@
import dayjs from 'dayjs'
import { ColumnInj, ReadonlyInj } from '~/context'
interface Props {
modelValue: string | null
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
interface Props {
modelValue: string
}
const columnMeta = inject(ColumnInj, null)
const readOnlyMode = inject(ReadonlyInj, false)
let isDateInvalid = $ref(false)
const dateFormat = columnMeta?.meta?.date_format ?? 'YYYY-MM-DD'
const dateFormat = columnMeta?.value?.meta?.date_format ?? 'YYYY-MM-DD'
const localState = $computed({
get() {

8
packages/nc-gui-v2/components/cell/DateTimePicker.vue

@ -2,14 +2,14 @@
import dayjs from 'dayjs'
import { ReadonlyInj } from '~/context'
interface Props {
modelValue: string | null
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
interface Props {
modelValue: string
}
const { isMysql } = useProject()
const readOnlyMode = inject(ReadonlyInj, false)

12
packages/nc-gui-v2/components/cell/Decimal.vue

@ -1,8 +1,9 @@
<script lang="ts" setup>
import { computed, inject, onMounted, ref } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: number | null
modelValue: number | null | string
}
interface Emits {
@ -13,25 +14,24 @@ const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const editEnabled = inject<boolean>('editEnabled')
const editEnabled = inject<boolean>(EditModeInj)
const root = ref<HTMLInputElement>()
const vModel = useVModel(props, 'modelValue', emits)
onMounted(() => {
root.value?.focus()
})
const focus = (el: HTMLInputElement) => el?.focus()
</script>
<template>
<input
v-if="editEnabled"
ref="root"
:ref="focus"
v-model="vModel"
class="outline-none pa-0 border-none w-full h-full prose-sm"
type="number"
step="0.1"
@blur="editEnabled = false"
/>
<span v-else class="prose-sm">{{ vModel }}</span>
</template>

4
packages/nc-gui-v2/components/cell/Duration.vue

@ -4,7 +4,7 @@ import { ColumnInj } from '~/context'
import { convertDurationToSeconds, convertMS2Duration, durationOptions } from '~/utils'
interface Props {
modelValue: number | string
modelValue: number | string | null
}
const { modelValue } = defineProps<Props>()
@ -16,7 +16,7 @@ const column = inject(ColumnInj)
const showWarningMessage = ref(false)
const durationInMS = ref(0)
const isEdited = ref(false)
const durationType = ref(column?.meta?.duration || 0)
const durationType = ref(column?.value?.meta?.duration || 0)
const durationPlaceholder = computed(() => durationOptions[durationType.value].title)
const localState = computed({

2
packages/nc-gui-v2/components/cell/Email.vue

@ -25,7 +25,7 @@ const focus = (el: HTMLInputElement) => el?.focus()
</script>
<template>
<input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none prose-sm" />
<input v-if="editEnabled" ref="root" v-model="vModel" class="outline-none prose-sm" @blur="editEnabled = false" />
<a v-else-if="validEmail" class="prose-sm underline hover:opacity-75" :href="`mailto:${vModel}`" target="_blank">
{{ vModel }}
</a>

3
packages/nc-gui-v2/components/cell/Float.vue

@ -3,7 +3,7 @@ import { inject, ref, useVModel } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: number
modelValue: number | null
}
interface Emits {
@ -29,6 +29,7 @@ const focus = (el: HTMLInputElement) => el?.focus()
class="outline-none pa-0 border-none w-full h-full prose-sm"
type="number"
step="0.1"
@blur="editEnabled = false"
/>
<span v-else class="prose-sm">{{ vModel }}</span>
</template>

1
packages/nc-gui-v2/components/cell/Integer.vue

@ -32,6 +32,7 @@ function onKeyDown(evt: KeyboardEvent) {
v-model="vModel"
class="outline-none pa-0 border-none w-full h-full prose-sm"
type="number"
@blur="editEnabled = false"
@keydown="onKeyDown"
/>
<span v-else class="prose-sm">{{ vModel }}</span>

123
packages/nc-gui-v2/components/cell/Json.vue

@ -0,0 +1,123 @@
<script setup lang="ts">
import { Modal as AModal } from 'ant-design-vue'
import Editor from '~/components/monaco/Editor.vue'
import FullScreenIcon from '~icons/cil/fullscreen'
import FullScreenExitIcon from '~icons/cil/fullscreen-exit'
import { inject } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: string | Record<string, any> | undefined
}
interface Emits {
(event: 'update:modelValue', model: string): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const editEnabled = inject(EditModeInj, ref(false))
let vModel = $(useVModel(props, 'modelValue', emits))
let localValueState = $ref<string | undefined>(undefined)
let localValue = $(
computed<string | undefined>({
get: () => localValueState,
set: (val: undefined | string | Record<string, any>) => {
localValueState = typeof val === 'object' ? JSON.stringify(val, null, 2) : val
},
}),
)
let error = $ref<string | undefined>(undefined)
let isExpanded = $ref(false)
const clear = () => {
error = undefined
isExpanded = false
editEnabled.value = false
localValue = vModel
}
const formatJson = (json: string) => {
try {
return JSON.stringify(JSON.parse(json), null, 2)
} catch (e) {
return json
}
}
const onSave = () => {
isExpanded = false
editEnabled.value = false
localValue = localValue ? formatJson(localValue) : localValue
vModel = localValue
}
watch(
$$(vModel),
(val) => {
localValue = val
},
{ immediate: true },
)
watch($$(localValue), (val) => {
try {
JSON.parse(val)
error = undefined
} catch (e: any) {
error = e
}
})
watch(editEnabled, () => {
isExpanded = false
localValue = vModel
})
</script>
<template>
<component :is="isExpanded ? AModal : 'div'" v-model:visible="isExpanded" :closable="false" centered :footer="null">
<div v-if="editEnabled" class="flex flex-col w-full">
<div class="flex flex-row justify-between pt-1 pb-2">
<a-button type="text" size="small" @click="isExpanded = !isExpanded">
<FullScreenExitIcon v-if="isExpanded" class="h-2.5" />
<FullScreenIcon v-else class="h-2.5" />
</a-button>
<div class="flex flex-row">
<a-button type="text" size="small" :onclick="clear"><div class="text-xs">Cancel</div></a-button>
<a-button type="primary" size="small" :disabled="!!error || localValue === vModel">
<div class="text-xs" :onclick="onSave">Save</div>
</a-button>
</div>
</div>
<Editor
:model-value="localValue"
class="min-w-full w-80"
:class="{ 'expanded-editor': isExpanded, 'editor': !isExpanded }"
:hide-minimap="true"
:disable-deep-compare="true"
@update:model-value="localValue = $event"
/>
<span v-if="error" class="text-xs w-full py-1 text-red-500">
{{ error.toString() }}
</span>
</div>
<span v-else>{{ vModel }}</span>
</component>
</template>
<style scoped lang="scss">
.expanded-editor {
min-height: min(600px, 80vh);
}
.editor {
min-height: min(200px, 10vh);
}
</style>

119
packages/nc-gui-v2/components/cell/JsonEditableCell.vue

@ -1,119 +0,0 @@
<script lang="ts" setup>
import MonacoJsonObjectEditor from '@/components/monaco/Editor.vue'
import { computed, inject } from '#imports'
import { EditModeInj } from '~/context'
interface Props {
modelValue: string | Record<string, any>
isForm: boolean
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel'])
const editEnabled = inject(EditModeInj)
let expand = $ref(false)
let isValid = $ref(true)
let error = $ref()
const vModel = computed({
get: () => (typeof props.modelValue === 'string' ? JSON.parse(props.modelValue) : props.modelValue),
set: (val) => {
if (props.isForm) {
emits('update:modelValue', JSON.stringify(val))
}
},
})
function save() {
expand = false
emits('update:modelValue', JSON.stringify(vModel.value))
}
function validate(n: boolean, e: any) {
isValid = n
error = e
}
</script>
<script lang="ts">
export default {
name: 'JsonEditableCell',
}
</script>
<template>
<v-dialog :is="expand ? 'v-dialog' : 'div'" v-model="expand" max-width="800px" class="cell-container" @keydown.stop.enter>
<div class="d-flex pa-1" :class="{ backgroundColor: expand }">
<v-spacer />
<v-icon small class="mr-2" @click="expand = !expand">
{{ expand ? 'mdi-arrow-collapse' : 'mdi-arrow-expand' }}
</v-icon>
<template v-if="!isForm">
<v-btn outlined x-small class="mr-1" @click="$emit('cancel')">
<!-- Cancel -->
{{ $t('general.cancel') }}
</v-btn>
<v-btn x-small color="primary" :disabled="!isValid" @click="save">
<!-- Save -->
{{ $t('general.save') }}
</v-btn>
</template>
<v-btn v-else-if="expand" x-small @click="expand = false">
<!-- Close -->
{{ $t('general.close') }}
</v-btn>
</div>
<MonacoJsonObjectEditor
v-if="expand"
v-model="vModel"
class="text-left caption"
style="width: 300px; min-height: min(600px, 80vh); min-width: 100%"
@validate="validate"
/>
<MonacoJsonObjectEditor
v-else
v-model="vModel"
class="text-left caption"
style="width: 300px; min-height: 200px; min-width: 100%"
@validate="validate"
/>
<div v-show="error" class="px-2 py-1 text-left caption error--text">
{{ error }}
</div>
</v-dialog>
</template>
<style scoped>
.cell-container {
width: 100%;
}
</style>
<!--
/**
* @copyright Copyright (c) 2021, Xgene Cloud Ltd
*
* @author Naveen MR <oof1lab@gmail.com>
* @author Pranav C Balan <pranavxc@gmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
-->

4
packages/nc-gui-v2/components/cell/MultiSelect.vue

@ -3,7 +3,7 @@ import { computed, inject } from '#imports'
import { ColumnInj, EditModeInj } from '~/context'
interface Props {
modelValue: string
modelValue: string | null
}
const { modelValue } = defineProps<Props>()
@ -14,7 +14,7 @@ const column = inject(ColumnInj)
const isForm = inject<boolean>('isForm', false)
const editEnabled = inject(EditModeInj, ref(false))
const options = computed(() => column?.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
const options = computed(() => column?.value?.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
const localState = computed({
get() {

6
packages/nc-gui-v2/components/cell/Percent.vue

@ -4,7 +4,7 @@ import { ColumnInj } from '~/context'
import { getPercentStep, isValidPercent, renderPercent } from '@/utils/percentUtils'
interface Props {
modelValue: number | string
modelValue: number | string | null
}
const { modelValue } = defineProps<Props>()
@ -17,7 +17,7 @@ const percent = ref()
const isEdited = ref(false)
const percentType = computed(() => column?.meta?.precision || 0)
const percentType = computed(() => column?.value?.meta?.precision || 0)
const percentStep = computed(() => getPercentStep(percentType.value))
@ -27,7 +27,7 @@ const localState = computed({
},
set: (val) => {
if (val === null) val = 0
if (isValidPercent(val, column?.meta?.negative)) {
if (isValidPercent(val, column?.value?.meta?.negative)) {
percent.value = val / 100
}
},

4
packages/nc-gui-v2/components/cell/Rating.vue

@ -8,7 +8,7 @@ import MdiThumbUpIcon from '~icons/mdi/thumb-up'
import MdiFlagIcon from '~icons/mdi/flag'
interface Props {
modelValue?: number
modelValue?: number | null
readOnly?: boolean
}
@ -26,7 +26,7 @@ const ratingMeta = computed(() => {
},
color: '#fcb401',
max: 5,
...(column?.meta || {}),
...(column?.value?.meta || {}),
}
})

4
packages/nc-gui-v2/components/cell/SingleSelect.vue

@ -3,7 +3,7 @@ import { computed, inject } from '#imports'
import { ColumnInj, EditModeInj } from '~/context'
interface Props {
modelValue: string
modelValue: string | null
}
const { modelValue } = defineProps<Props>()
@ -19,7 +19,7 @@ const vModel = computed({
set: (val) => emit('update:modelValue', val),
})
const options = computed(() => column?.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
const options = computed(() => column?.value?.dtxp?.split(',').map((v) => v.replace(/\\'/g, "'").replace(/^'|'$/g, '')) || [])
</script>
<template>

8
packages/nc-gui-v2/components/cell/TimePicker.vue

@ -2,14 +2,14 @@
import dayjs from 'dayjs'
import { ReadonlyInj } from '~/context'
interface Props {
modelValue: string | null
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
interface Props {
modelValue: string
}
const { isMysql } = useProject()
const readOnlyMode = inject(ReadonlyInj, false)

6
packages/nc-gui-v2/components/cell/Url.vue

@ -4,7 +4,7 @@ import { ColumnInj, EditModeInj } from '~/context'
import { isValidURL } from '~/utils'
interface Props {
modelValue: string
modelValue: string | null
}
const { modelValue: value } = defineProps<Props>()
@ -18,7 +18,7 @@ const editEnabled = inject(EditModeInj, ref(false))
const vModel = computed({
get: () => value,
set: (val) => {
if (!(column && column.meta && column.meta.validate) || isValidURL(val)) {
if (!column?.value?.meta?.validate || isValidURL(val)) {
emit('update:modelValue', val)
}
},
@ -30,7 +30,7 @@ const focus = (el: HTMLInputElement) => el?.focus()
</script>
<template>
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none" />
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none" @blur="editEnabled = false" />
<nuxt-link v-else-if="isValid" class="py-2 underline hover:opacity-75" :to="value" target="_blank">{{ value }}</nuxt-link>
<span v-else>{{ value }}</span>
</template>

8
packages/nc-gui-v2/components/cell/YearPicker.vue

@ -2,14 +2,14 @@
import dayjs from 'dayjs'
import { ReadonlyInj } from '~/context'
interface Props {
modelValue: number | string | null
}
const { modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
interface Props {
modelValue: number
}
const readOnlyMode = inject(ReadonlyInj, false)
let isYearInvalid = $ref(false)

2
packages/nc-gui-v2/components/cell/attachment/utils.ts

@ -78,7 +78,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
try {
const data = await api.storage.upload(
{
path: [NOCO, project.value.title, meta.value.title, column.title].join('/'),
path: [NOCO, project.value.title, meta.value.title, column.value.title].join('/'),
},
{
files: file,

72
packages/nc-gui-v2/components/monaco/Editor.vue

@ -7,14 +7,37 @@ import { onMounted } from '#imports'
import { deepCompare } from '~/utils'
interface Props {
modelValue: string
modelValue: string | Record<string, any>
hideMinimap?: boolean
lang?: string
validate?: boolean
disableDeepCompare?: boolean
}
const { modelValue, lang = 'json', validate = true } = defineProps<Props>()
const { hideMinimap, lang = 'json', validate = true, disableDeepCompare = false, modelValue } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const emits = defineEmits(['update:modelValue'])
let vModel = $computed<string>({
get: () => {
if (typeof modelValue === 'object') {
return JSON.stringify(modelValue, null, 2)
} else {
return modelValue
}
},
set: (newVal: string | Record<string, any>) => {
if (typeof modelValue === 'object') {
try {
emits('update:modelValue', typeof newVal === 'object' ? newVal : JSON.parse(newVal))
} catch (e) {
console.error(e)
}
} else {
emits('update:modelValue', newVal)
}
},
})
const isValid = ref(true)
@ -49,7 +72,8 @@ defineExpose({
onMounted(() => {
if (root.value && lang) {
const model = monaco.editor.createModel(JSON.stringify(modelValue, null, 2), lang)
const model = monaco.editor.createModel(vModel, lang)
if (lang === 'json') {
// configure the JSON language support with schemas and schema associations
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
@ -68,19 +92,20 @@ onMounted(() => {
},
tabSize: 2,
automaticLayout: true,
minimap: {
enabled: !hideMinimap,
},
})
editor.onDidChangeModelContent(async (e) => {
editor.onDidChangeModelContent(async () => {
try {
isValid.value = true
const value = editor?.getValue()
if (typeof modelValue === 'object') {
if (!value || !deepCompare(modelValue, JSON.parse(value))) {
emit('update:modelValue', JSON.stringify(modelValue, null, 2))
}
if (disableDeepCompare) {
vModel = editor.getValue()
} else {
if (value !== modelValue) emit('update:modelValue', value)
const obj = JSON.parse(editor.getValue())
if (!obj || !deepCompare(vModel, obj)) vModel = obj
}
} catch (e) {
isValid.value = false
@ -90,21 +115,18 @@ onMounted(() => {
}
})
watch(
() => modelValue,
(v) => {
if (editor && v) {
const value = editor?.getValue()
if (typeof v === 'object') {
if (!value || !deepCompare(v, JSON.parse(value))) {
editor.setValue(JSON.stringify(v, null, 2))
}
} else {
if (value !== v) editor.setValue(v)
}
watch($$(vModel), (v) => {
if (!editor || !v) return
const editorValue = editor?.getValue()
if (!disableDeepCompare) {
if (!editorValue || !deepCompare(JSON.parse(v), JSON.parse(editorValue))) {
editor.setValue(v)
}
},
)
} else {
if (editorValue !== v) editor.setValue(v)
}
})
</script>
<template>

9
packages/nc-gui-v2/components/smartsheet-column/AdvancedOptions.vue

@ -77,13 +77,8 @@ formState.value.au = !!formState.value.au
<a-input v-model="formState.dtxs" :disabled="!sqlUi.columnEditable(formState)" size="small" @input="onAlter" />
</a-form-item>
<a-form-item :label="$t('placeholder.defaultValue')">
<a-textarea
v-model="formState.cdf"
:help="sqlUi.getDefaultValueForDatatype(formState.dt)"
size="small"
auto-size
@input="onAlter(2, true)"
/>
<a-textarea v-model:value="formState.cdf" size="small" auto-size @input="onAlter(2, true)" />
<span class="text-gray-400 text-xs">{{ sqlUi.getDefaultValueForDatatype(formState.dt) }}</span>
</a-form-item>
</div>
</template>

80
packages/nc-gui-v2/components/smartsheet-column/EditOrAdd.vue

@ -1,16 +1,26 @@
<script lang="ts" setup>
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { computed, inject, useColumnCreateStoreOrThrow, useMetas, watchEffect } from '#imports'
import { MetaInj } from '~/context'
import { MetaInj, ReloadViewDataHookInj } from '~/context'
import { uiTypes } from '~/utils/columnUtils'
import MdiPlusIcon from '~icons/mdi/plus-circle-outline'
import MdiMinusIcon from '~icons/mdi/minus-circle-outline'
import MdiIdentifierIcon from '~icons/mdi/identifier'
interface Props {
editColumnDropdown: boolean
}
const { editColumnDropdown } = defineProps<Props>()
const emit = defineEmits(['cancel', 'submit'])
const meta = inject(MetaInj)
const reloadDataTrigger = inject(ReloadViewDataHookInj)
const advancedOptions = ref(false)
const { getMeta } = useMetas()
const formulaOptionsRef = ref()
const {
formState,
resetFields,
@ -27,35 +37,36 @@ const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const uiTypesOptions = computed<typeof uiTypes>(() => {
return [
...uiTypes.filter((t) => !isEdit || !t.virtual),
...(!isEdit && meta?.value?.columns?.every((c) => !c.pk)
...uiTypes.filter((t) => !isEdit.value || !t.virtual),
...(!isEdit.value && meta?.value?.columns?.every((c) => !c.pk)
? [
{
name: 'ID',
icon: 'mdi-identifier',
name: UITypes.ID,
icon: MdiIdentifierIcon,
virtual: 0,
},
]
: []),
]
})
const reloadMeta = async () => {
const reloadMetaAndData = () => {
emit('cancel')
await getMeta(meta?.value.id as string, true)
getMeta(meta?.value.id as string, true)
reloadDataTrigger?.trigger()
}
async function handleSubmit() {
// FIXME: emit only works when putting before addOrUpdate
await addOrUpdate(async () => {
await reloadMeta()
advancedOptions.value = false
})
emit('submit')
function onCancel() {
emit('cancel')
if (formState.value.uidt === UITypes.Formula) {
// close formula drawer
formulaOptionsRef.value.formulaSuggestionDrawer = false
}
}
// create column meta if it's a new column
watchEffect(() => {
if (!isEdit) {
if (!isEdit.value) {
generateNewColumnMeta()
}
})
@ -71,6 +82,17 @@ watchEffect(() => {
}, 300)
}
})
watch(
() => editColumnDropdown,
(v) => {
if (v) {
if (formState.value.uidt === UITypes.Formula) {
formulaOptionsRef.value.formulaSuggestionDrawer = true
}
}
},
)
</script>
<template>
@ -86,8 +108,14 @@ watchEffect(() => {
/>
</a-form-item>
<a-form-item :label="$t('labels.columnType')">
<a-select v-model:value="formState.uidt" size="small" class="nc-column-name-input" @change="onUidtOrIdTypeChange">
<a-select-option v-for="opt in uiTypesOptions" :key="opt.name" :value="opt.name" v-bind="validateInfos.uidt">
<a-select
v-model:value="formState.uidt"
show-search
size="small"
class="nc-column-name-input"
@change="onUidtOrIdTypeChange"
>
<a-select-option v-for="opt of uiTypesOptions" :key="opt.name" :value="opt.name" v-bind="validateInfos.uidt">
<div class="flex gap-1 align-center text-xs">
<component :is="opt.icon" class="text-grey" />
{{ opt.name }}
@ -96,6 +124,7 @@ watchEffect(() => {
</a-select>
</a-form-item>
<SmartsheetColumnFormulaOptions v-if="formState.uidt === UITypes.Formula" ref="formulaOptionsRef" />
<SmartsheetColumnCurrencyOptions v-if="formState.uidt === UITypes.Currency" />
<SmartsheetColumnDurationOptions v-if="formState.uidt === UITypes.Duration" />
<SmartsheetColumnRatingOptions v-if="formState.uidt === UITypes.Rating" />
@ -122,7 +151,7 @@ watchEffect(() => {
v-model:checked="formState.meta.validate"
class="ml-1 mb-1"
>
<span class="text-xs text-gray-600">
<span class="text-[10px] text-gray-600">
{{ `Accept only valid ${formState.uidt}` }}
</span>
</a-checkbox>
@ -130,11 +159,22 @@ watchEffect(() => {
</div>
<a-form-item>
<div class="flex justify-end gap-1 mt-4">
<a-button html-type="button" size="small" @click="emit('cancel')">
<a-button html-type="button" size="small" @click="onCancel">
<!-- Cancel -->
{{ $t('general.cancel') }}
</a-button>
<a-button html-type="submit" type="primary" size="small" @click="handleSubmit">
<a-button
html-type="submit"
type="primary"
size="small"
@click="
() => {
addOrUpdate(reloadMetaAndData)
advancedOptions = false
emit('cancel')
}
"
>
<!-- Save -->
{{ $t('general.save') }}
</a-button>

683
packages/nc-gui-v2/components/smartsheet-column/FormulaOptions.vue

@ -0,0 +1,683 @@
<script setup lang="ts">
import type { Ref } from 'vue'
import type { ListItem as AntListItem } from 'ant-design-vue'
import jsep from 'jsep'
import type { ColumnType } from 'nocodb-sdk'
import { UITypes, jsepCurlyHook } from 'nocodb-sdk'
import { useColumnCreateStoreOrThrow, useDebounceFn } from '#imports'
import { MetaInj } from '~/context'
import {
NcAutocompleteTree,
formulaList,
formulaTypes,
formulas,
getUIDTIcon,
getWordUntilCaret,
insertAtCursor,
validateDateWithUnknownFormat,
} from '@/utils'
import MdiFunctionIcon from '~icons/mdi/function'
import MdiOperatorIcon from '~icons/mdi/calculator'
enum JSEPNode {
COMPOUND = 'Compound',
IDENTIFIER = 'Identifier',
MEMBER_EXP = 'MemberExpression',
LITERAL = 'Literal',
THIS_EXP = 'ThisExpression',
CALL_EXP = 'CallExpression',
UNARY_EXP = 'UnaryExpression',
BINARY_EXP = 'BinaryExpression',
ARRAY_EXP = 'ArrayExpression',
}
const { formState, validateInfos, setAdditionalValidations, sqlUi, onDataTypeChange, onAlter, column } =
useColumnCreateStoreOrThrow()
const meta = inject(MetaInj)
const columns = computed(() => meta?.value?.columns || [])
const validators = {
formula_raw: [
{
validator: (_: any, formula: any) => {
return new Promise<void>((resolve, reject) => {
const res = parseAndValidateFormula(formula)
if (res !== true) {
return reject(new Error(res))
}
resolve()
})
},
},
],
}
const formulaSuggestionDrawer = ref(true)
const availableFunctions = formulaList
const availableBinOps = ['+', '-', '*', '/', '>', '<', '==', '<=', '>=', '!=']
const autocomplete = ref(false)
const formulaRef = ref()
const sugListRef = ref()
const sugOptionsRef = ref<typeof AntListItem[]>([])
const wordToComplete = ref<string | undefined>('')
const selected = ref(0)
const tooltip = ref(true)
const sortOrder: Record<string, number> = {
column: 0,
function: 1,
op: 2,
}
const suggestionsList = computed(() => {
const unsupportedFnList = sqlUi.value.getUnsupportedFnList()
return [
...availableFunctions
.filter((fn: string) => !unsupportedFnList.includes(fn))
.map((fn: string) => ({
text: `${fn}()`,
type: 'function',
description: formulas[fn].description,
syntax: formulas[fn].syntax,
examples: formulas[fn].examples,
})),
...columns.value
.filter(
(c: Record<string, any>) =>
!column || (column.id !== c.id && !(c.uidt === UITypes.LinkToAnotherRecord && c.system === 1)),
)
.map((c: any) => ({
text: c.title,
type: 'column',
icon: getUIDTIcon(c.uidt),
})),
...availableBinOps.map((op: string) => ({
text: op,
type: 'op',
})),
]
})
// set default suggestion list
const suggestion: Ref<Record<string, any>[]> = ref(suggestionsList.value)
const acTree = computed(() => {
const ref = new NcAutocompleteTree()
for (const sug of suggestionsList.value) {
ref.add(sug)
}
return ref
})
function parseAndValidateFormula(formula: string) {
try {
const parsedTree = jsep(formula)
const metaErrors = validateAgainstMeta(parsedTree)
if (metaErrors.size) {
return [...metaErrors].join(', ')
}
return true
} catch (e: any) {
return e.message
}
}
function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) {
if (parsedTree.type === JSEPNode.CALL_EXP) {
// validate function name
if (!availableFunctions.includes(parsedTree.callee.name)) {
errors.add(`'${parsedTree.callee.name}' function is not available`)
}
// validate arguments
const validation = formulas[parsedTree.callee.name] && formulas[parsedTree.callee.name].validation
if (validation && validation.args) {
if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required ${validation.args.rqd} arguments`)
} else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required minimum ${validation.args.min} arguments`)
} else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required maximum ${validation.args.max} arguments`)
}
}
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstMeta(arg, errors))
// validate data type
if (parsedTree.callee.type === JSEPNode.IDENTIFIER) {
const expectedType = formulas[parsedTree.callee.name].type
if (expectedType === formulaTypes.NUMERIC) {
if (parsedTree.callee.name === 'WEEKDAY') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add('The first parameter of WEEKDAY() should have date value')
}
},
typeErrors,
)
// parsedTree.arguments[1] = startDayOfWeek (optional)
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.STRING,
(v: any) => {
if (
typeof v !== 'string' ||
!['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].includes(v.toLowerCase())
) {
typeErrors.add(
'The second parameter of WEEKDAY() should have the value either "sunday", "monday", "tuesday", "wednesday", "thursday", "friday" or "saturday"',
)
}
},
typeErrors,
)
} else {
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstType(arg, expectedType, null, typeErrors))
}
} else if (expectedType === formulaTypes.DATE) {
if (parsedTree.callee.name === 'DATEADD') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
formulaTypes.DATE,
(v: any) => {
if (!validateDateWithUnknownFormat(v)) {
typeErrors.add('The first parameter of DATEADD() should have date value')
}
},
typeErrors,
)
// parsedTree.arguments[1] = numeric
validateAgainstType(
parsedTree.arguments[1],
formulaTypes.NUMERIC,
(v: any) => {
if (typeof v !== 'number') {
typeErrors.add('The second parameter of DATEADD() should have numeric value')
}
},
typeErrors,
)
// parsedTree.arguments[2] = ["day" | "week" | "month" | "year"]
validateAgainstType(
parsedTree.arguments[2],
formulaTypes.STRING,
(v: any) => {
if (!['day', 'week', 'month', 'year'].includes(v)) {
typeErrors.add('The third parameter of DATEADD() should have the value either "day", "week", "month" or "year"')
}
},
typeErrors,
)
}
}
}
errors = new Set([...errors, ...typeErrors])
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
if (
columns.value
.filter((c: Record<string, any>) => !column || column.id !== c.id)
.every((c: Record<string, any>) => c.title !== parsedTree.name)
) {
errors.add(`Column '${parsedTree.name}' is not available`)
}
// check circular reference
// e.g. formula1 -> formula2 -> formula1 should return circular reference error
// get all formula columns excluding itself
const formulaPaths = columns.value
.filter((c: Record<string, any>) => c.id !== column?.id && c.uidt === UITypes.Formula)
.reduce((res: Record<string, any>[], c: Record<string, any>) => {
// in `formula`, get all the target neighbours
// i.e. all column id (e.g. cl_xxxxxxxxxxxxxx) with formula type
const neighbours = (c.colOptions.formula.match(/cl_\w{14}/g) || []).filter(
(colId: string) => columns.value.filter((col: ColumnType) => col.id === colId && col.uidt === UITypes.Formula).length,
)
if (neighbours.length > 0) {
// e.g. formula column 1 -> [formula column 2, formula column3]
res.push({ [c.id]: neighbours })
}
return res
}, [])
// include target formula column (i.e. the one to be saved if applicable)
const targetFormulaCol = columns.value.find((c: ColumnType) => c.title === parsedTree.name && c.uidt === UITypes.Formula)
if (targetFormulaCol) {
formulaPaths.push({
[column.id]: [targetFormulaCol.id],
})
}
const vertices = formulaPaths.length
if (vertices > 0) {
// perform kahn's algo for cycle detection
const adj = new Map()
const inDegrees = new Map()
// init adjacency list & indegree
for (const [_, v] of Object.entries(formulaPaths)) {
const src = Object.keys(v)[0]
const neighbours = v[src]
inDegrees.set(src, inDegrees.get(src) || 0)
for (const neighbour of neighbours) {
adj.set(src, (adj.get(src) || new Set()).add(neighbour))
inDegrees.set(neighbour, (inDegrees.get(neighbour) || 0) + 1)
}
}
const queue: string[] = []
// put all vertices with in-degree = 0 (i.e. no incoming edges) to queue
inDegrees.forEach((inDegree, col) => {
if (inDegree === 0) {
// in-degree = 0 means we start traversing from this node
queue.push(col)
}
})
// init count of visited vertices
let visited = 0
// BFS
while (queue.length !== 0) {
// remove a vertex from the queue
const src = queue.shift()
// if this node has neighbours, increase visited by 1
const neighbours = adj.get(src) || new Set()
if (neighbours.size > 0) {
visited += 1
}
// iterate each neighbouring nodes
neighbours.forEach((neighbour: string) => {
// decrease in-degree of its neighbours by 1
inDegrees.set(neighbour, inDegrees.get(neighbour) - 1)
// if in-degree becomes 0
if (inDegrees.get(neighbour) === 0) {
// then put the neighboring node to the queue
queue.push(neighbour)
}
})
}
// vertices not same as visited = cycle found
if (vertices !== visited) {
errors.add('Can’t save field because it causes a circular reference')
}
}
} else if (parsedTree.type === JSEPNode.BINARY_EXP) {
if (!availableBinOps.includes(parsedTree.operator)) {
errors.add(`'${parsedTree.operator}' operation is not available`)
}
validateAgainstMeta(parsedTree.left, errors)
validateAgainstMeta(parsedTree.right, errors)
} else if (parsedTree.type === JSEPNode.LITERAL || parsedTree.type === JSEPNode.UNARY_EXP) {
// do nothing
} else if (parsedTree.type === JSEPNode.COMPOUND) {
if (parsedTree.body.length) {
errors.add('Can’t save field because the formula is invalid')
}
} else {
errors.add('Can’t save field because the formula is invalid')
}
return errors
}
function validateAgainstType(parsedTree: any, expectedType: string, func: any, typeErrors = new Set()) {
if (parsedTree === false || typeof parsedTree === 'undefined') {
return typeErrors
}
if (parsedTree.type === JSEPNode.LITERAL) {
if (typeof func === 'function') {
func(parsedTree.value)
} else if (expectedType === formulaTypes.NUMERIC) {
if (typeof parsedTree.value !== 'number') {
typeErrors.add('Numeric type is expected')
}
} else if (expectedType === formulaTypes.STRING) {
if (typeof parsedTree.value !== 'string') {
typeErrors.add('string type is expected')
}
}
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = columns.value.find((c) => c.title === parsedTree.name) as Record<string, any>
if (col === undefined) {
return
}
if (col.uidt === UITypes.Formula) {
const foundType = getRootDataType(jsep(col?.formula_raw))
if (foundType === 'N/A') {
typeErrors.add(`Not supported to reference column ${col.title}`)
} else if (expectedType !== foundType) {
typeErrors.add(`Type ${expectedType} is expected but found Type ${foundType}`)
}
} else {
switch (col.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
if (expectedType !== formulaTypes.STRING) {
typeErrors.add(
`Column '${parsedTree.name}' with ${formulaTypes.STRING} type is found but ${expectedType} type is expected`,
)
}
break
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
case UITypes.Currency:
if (expectedType !== formulaTypes.NUMERIC) {
typeErrors.add(
`Column '${parsedTree.name}' with ${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`,
)
}
break
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
if (expectedType !== formulaTypes.DATE) {
typeErrors.add(
`Column '${parsedTree.name}' with ${formulaTypes.DATE} type is found but ${expectedType} type is expected`,
)
}
break
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Rollup:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
default:
typeErrors.add(`Not supported to reference column '${parsedTree.name}'`)
break
}
}
} else if (parsedTree.type === JSEPNode.UNARY_EXP || parsedTree.type === JSEPNode.BINARY_EXP) {
if (expectedType !== formulaTypes.NUMERIC) {
// parsedTree.name won't be available here
typeErrors.add(`${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`)
}
} else if (parsedTree.type === JSEPNode.CALL_EXP) {
if (formulas[parsedTree.callee.name]?.type && expectedType !== formulas[parsedTree.callee.name].type) {
typeErrors.add(`${expectedType} not matched with ${formulas[parsedTree.callee.name].type}`)
}
}
return typeErrors
}
function getRootDataType(parsedTree: any): any {
// given a parse tree, return the data type of it
if (parsedTree.type === JSEPNode.CALL_EXP) {
return formulas[parsedTree.callee.name].type
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = columns.value.find((c) => c.title === parsedTree.name) as Record<string, any>
if (col?.uidt === UITypes.Formula) {
return getRootDataType(jsep(col?.formula_raw))
} else {
switch (col?.uidt) {
// string
case UITypes.SingleLineText:
case UITypes.LongText:
case UITypes.MultiSelect:
case UITypes.SingleSelect:
case UITypes.PhoneNumber:
case UITypes.Email:
case UITypes.URL:
return formulaTypes.STRING
// numeric
case UITypes.Year:
case UITypes.Number:
case UITypes.Decimal:
case UITypes.Rating:
case UITypes.Count:
case UITypes.AutoNumber:
return formulaTypes.NUMERIC
// date
case UITypes.Date:
case UITypes.DateTime:
case UITypes.CreateTime:
case UITypes.LastModifiedTime:
return formulaTypes.DATE
// not supported
case UITypes.ForeignKey:
case UITypes.Attachment:
case UITypes.ID:
case UITypes.Time:
case UITypes.Currency:
case UITypes.Percent:
case UITypes.Duration:
case UITypes.Rollup:
case UITypes.Lookup:
case UITypes.Barcode:
case UITypes.Button:
case UITypes.Checkbox:
case UITypes.Collaborator:
default:
return 'N/A'
}
}
} else if (parsedTree.type === JSEPNode.BINARY_EXP || parsedTree.type === JSEPNode.UNARY_EXP) {
return formulaTypes.NUMERIC
} else if (parsedTree.type === JSEPNode.LITERAL) {
return typeof parsedTree.value
} else {
return 'N/A'
}
}
function isCurlyBracketBalanced() {
// count number of opening curly brackets and closing curly brackets
const cntCurlyBrackets = (formulaRef.value.$el.value.match(/\{|}/g) || []).reduce(
(acc: Record<number, number>, cur: number) => {
acc[cur] = (acc[cur] || 0) + 1
return acc
},
{},
)
return (cntCurlyBrackets['{'] || 0) === (cntCurlyBrackets['}'] || 0)
}
function appendText(item: Record<string, any>) {
const text = item.text
const len = wordToComplete.value?.length || 0
if (item.type === 'function') {
formState.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len, 1)
} else if (item.type === 'column') {
formState.value.formula_raw = insertAtCursor(formulaRef.value.$el, `{${text}}`, len + +!isCurlyBracketBalanced())
} else {
formState.value.formula_raw = insertAtCursor(formulaRef.value.$el, text, len)
}
autocomplete.value = false
if (item.type === 'function' || item.type === 'op') {
// if function / operator is chosen, display columns only
suggestion.value = suggestionsList.value.filter((f) => f.type === 'column')
} else {
// show all options if column is chosen
suggestion.value = suggestionsList.value
}
}
const handleInputDeb = useDebounceFn(function () {
handleInput()
}, 250)
function handleInput() {
selected.value = 0
suggestion.value = []
const query = getWordUntilCaret(formulaRef.value.$el)
const parts = query.split(/\W+/)
wordToComplete.value = parts.pop() || ''
suggestion.value = acTree.value
.complete(wordToComplete.value)
?.sort((x: Record<string, any>, y: Record<string, any>) => sortOrder[x.type] - sortOrder[y.type])
if (!isCurlyBracketBalanced()) {
suggestion.value = suggestion.value.filter((v: Record<string, any>) => v.type === 'column')
}
autocomplete.value = !!suggestion.value.length
}
function selectText() {
if (suggestion.value && selected.value > -1 && selected.value < suggestion.value.length) {
appendText(suggestion.value[selected.value])
}
}
function suggestionListUp() {
if (suggestion.value) {
selected.value = --selected.value > -1 ? selected.value : suggestion.value.length - 1
scrollToSelectedOption()
}
}
function suggestionListDown() {
if (suggestion.value) {
selected.value = ++selected.value % suggestion.value.length
scrollToSelectedOption()
}
}
function scrollToSelectedOption() {
nextTick(() => {
if (sugOptionsRef.value[selected.value]) {
try {
sugListRef.value.$el.scrollTo({
top: sugOptionsRef.value[selected.value].$el.offsetTop,
behavior: 'smooth',
})
} catch (e) {}
}
})
}
function getFormulaTypeName(type: string) {
switch (type) {
case 'function':
return 'Function'
case 'op':
return 'Operator'
case 'column':
return 'Column'
default:
return ''
}
}
// set default value
formState.value.formula_raw = (column?.colOptions as Record<string, any>)?.formula_raw || ''
// set additional validations
setAdditionalValidations({
...validators,
})
defineExpose({
formulaSuggestionDrawer,
})
onMounted(() => {
jsep.plugins.register(jsepCurlyHook)
})
</script>
<template>
<div class="formula-wrapper">
<a-form-item v-bind="validateInfos.formula_raw" label="Formula">
<a-input
ref="formulaRef"
v-model:value="formState.formula_raw"
class="mb-2"
@keydown.down.prevent="suggestionListDown"
@keydown.up.prevent="suggestionListUp"
@keydown.enter.prevent="selectText"
@change="handleInputDeb"
/>
</a-form-item>
<div class="text-gray-600 mt-2 prose-sm">
Hint: Use {} to reference columns, e.g: {column_name}. For more, please check out
<a class="prose-sm" href="https://docs.nocodb.com/setup-and-usages/formulas#available-formula-features" target="_blank"
>Formulas</a
>.
</div>
<a-drawer
v-model:visible="formulaSuggestionDrawer"
:closable="false"
:mask="false"
:mask-closable="false"
placement="right"
width="500px"
class="h-full overflow-auto"
>
<a-list ref="sugListRef" :data-source="suggestion" :locale="{ emptyText: 'No suggested formula was found' }">
<template #renderItem="{ item, index }">
<a-list-item
:ref="
(el) => {
sugOptionsRef[index] = el
}
"
class="cursor-pointer"
@click.prevent.stop="appendText(item)"
>
<a-list-item-meta>
<template v-if="item.type === 'function'" #description>
{{ item.description }} <br /><br />
Syntax: <br />
{{ item.syntax }} <br /><br />
Examples: <br />
<div v-for="(example, idx) of item.examples" :key="idx">
<div>({{ idx + 1 }}): {{ example }}</div>
</div>
</template>
<template #title>
<div class="flex">
<div class="flex-1">
{{ item.text }}
</div>
<div class="">
{{ getFormulaTypeName(item.type) }}
</div>
</div>
</template>
<template #avatar>
<MdiFunctionIcon v-if="item.type === 'function'" class="text-lg" />
<MdiOperatorIcon v-if="item.type === 'op'" class="text-lg" />
<component :is="item.icon" v-if="item.type === 'column'" class="text-lg" />
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-drawer>
</div>
</template>

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

@ -7,7 +7,7 @@ import { useProvideColumnCreateStore } from '#imports'
interface Props {
required?: boolean
column: ColumnType & { meta: any }
column: ColumnType
}
const { column, required } = defineProps<Props>()

9
packages/nc-gui-v2/components/smartsheet-header/CellIcon.vue

@ -1,5 +1,7 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { toRef } from 'vue'
import { ColumnInj } from '~/context'
import FilePhoneIcon from '~icons/mdi/file-phone'
import { useColumn } from '#imports'
@ -24,14 +26,15 @@ import CurrencyIcon from '~icons/mdi/currency-usd-circle-outline'
import PercentIcon from '~icons/mdi/percent-outline'
import DecimalIcon from '~icons/mdi/decimal'
const { columnMeta } = defineProps<{ columnMeta?: ColumnType }>()
const props = defineProps<{ columnMeta?: ColumnType }>()
const columnMeta = toRef(props, 'columnMeta')
const column = inject(ColumnInj, columnMeta)
const additionalColMeta = useColumn(column as ColumnType)
const additionalColMeta = useColumn(column as Ref<ColumnType>)
const icon = computed(() => {
if (column?.pk) {
if (column?.value?.pk) {
return KeyIcon
} else if (additionalColMeta.isJSON) {
return JSONIcon

20
packages/nc-gui-v2/components/smartsheet-header/Menu.vue

@ -13,7 +13,7 @@ import MdiDeleteIcon from '~icons/mdi/delete-outline'
import MdiMenuDownIcon from '~icons/mdi/menu-down'
const { virtual = false } = defineProps<{ virtual?: boolean }>()
const editColumnDropdown = $ref(false)
const editColumnDropdown = ref(false)
const column = inject(ColumnInj)
const meta = inject(MetaInj)
@ -25,13 +25,13 @@ const { getMeta } = useMetas()
const deleteColumn = () =>
Modal.confirm({
title: h('div', ['Do you want to delete ', h('span', { class: 'font-weight-bold' }, [column?.title]), ' column ?']),
title: h('div', ['Do you want to delete ', h('span', { class: 'font-weight-bold' }, [column?.value?.title]), ' column ?']),
okText: t('general.delete'),
okType: 'danger',
cancelText: t('general.cancel'),
async onOk() {
try {
await $api.dbTableColumn.delete(column?.id as string)
await $api.dbTableColumn.delete(column?.value?.id as string)
getMeta(meta?.value?.id as string, true)
} catch (e) {
toast.error(await extractSdkResponseErrorMsg(e))
@ -41,7 +41,7 @@ const deleteColumn = () =>
const setAsPrimaryValue = async () => {
try {
await $api.dbTableColumn.primaryColumnSet(column?.id as string)
await $api.dbTableColumn.primaryColumnSet(column?.value?.id as string)
getMeta(meta?.value?.id as string, true)
toast.success('Successfully updated as primary column')
} catch (e) {
@ -49,20 +49,26 @@ const setAsPrimaryValue = async () => {
toast.error('Failed to update primary column')
}
}
function onVisibleChange() {
// only allow to close the EditOrAdd component
// by clicking cancel button
editColumnDropdown.value = true
}
</script>
<template>
<a-dropdown v-model:visible="editColumnDropdown" :trigger="['click']">
<a-dropdown v-model:visible="editColumnDropdown" :trigger="['click']" @visible-change="onVisibleChange">
<span />
<template #overlay>
<SmartsheetColumnEditOrAdd @click.stop @cancel="editColumnDropdown = false" />
<SmartsheetColumnEditOrAdd :edit-column-dropdown="editColumnDropdown" @click.stop @cancel="editColumnDropdown = false" />
</template>
</a-dropdown>
<a-dropdown :trigger="['hover']">
<MdiMenuDownIcon class="text-grey" />
<template #overlay>
<div class="shadow bg-white">
<div v-if="!virtual" class="nc-column-edit nc-menu-item" @click="editColumnDropdown = true">
<div class="nc-column-edit nc-menu-item" @click="editColumnDropdown = true">
<MdiEditIcon class="text-primary" />
<!-- Edit -->
{{ $t('general.edit') }}

211
packages/nc-gui-v2/components/smartsheet-header/VirtualCell.vue

@ -1,8 +1,12 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import { inject } from 'vue'
import { ColumnInj, IsFormInj } from '~/context'
import { provide } from '#imports'
import { substituteColumnIdWithAliasInFormula } from 'nocodb-sdk'
import type { ColumnType, FormulaType, LinkToAnotherRecordType, LookupType, RollupType, TableType } from 'nocodb-sdk'
import { toRef } from 'vue'
import { $computed } from 'vue/macros'
import type { Ref } from 'vue'
import { useMetas } from '~/composables'
import { ColumnInj, MetaInj, IsFormInj } from '~/context'
import { provide, useProvideColumnCreateStore } from '#imports'
interface Props {
required?: boolean
@ -15,130 +19,73 @@ provide(ColumnInj, column)
const isForm = inject(IsFormInj)
// import { UITypes } from 'nocodb-sdk'
// import { getUIDTIcon } from '../helpers/uiTypes'
// import EditVirtualColumn from '~/components/project/spreadsheet/components/EditVirtualColumn'
//
// export default {
// name: 'VirtualHeaderCell',
// components: { EditVirtualColumn },
// props: ['column', 'nodes', 'meta', 'isForm', 'isPublicView', 'sqlUi', 'required', 'isLocked', 'isVirtual'],
// data: () => ({
// columnDeleteDialog: false,
// editColumnMenu: false,
// rollupIcon: getUIDTIcon('Rollup'),
// rels: ['bt', 'hm', 'mm']
// }),
// computed: {
// alias() {
// // return this.column.lk ? `${this.column.lk._lcn} <small class="grey--text text--darken-1">(from ${this.column.lk._ltn})</small>` : this.column.title
// return this.column.title
// },
// type() {
// if (this.column?.colOptions?.type) {
// return this.column.colOptions.type
// }
// if (this.column?.colOptions?.formula) {
// return 'formula'
// }
// if (this.column.uidt === UITypes.Lookup) {
// return 'lk'
// }
// if (this.column.uidt === UITypes.Rollup) {
// return 'rl'
// }
// return ''
// },
// relation() {
// if (this.rels.includes(this.type)) {
// return this.column
// } else if (this.column.colOptions?.fk_relation_column_id) {
// return this.meta.columns.find(c => c.id === this.column.colOptions?.fk_relation_column_id)
// }
// return undefined
// },
// relationType() {
// return this.relation?.colOptions?.type
// },
// relationMeta() {
// if (this.rels.includes(this.type)) {
// return this.getMeta(this.column.colOptions.fk_related_model_id)
// } else if (this.relation) {
// return this.getMeta(this.relation.colOptions.fk_related_model_id)
// }
// return undefined
// },
// childColumn() {
// if (this.relationMeta?.columns) {
// if (this.type === 'rl') {
// const ch = this.relationMeta.columns.find(c => c.id === this.column.colOptions.fk_rollup_column_id)
// return ch
// }
// if (this.type === 'lk') {
// const ch = this.relationMeta.columns.find(c => c.id === this.column.colOptions.fk_lookup_column_id)
// return ch
// }
// }
// return ''
// },
// childTable() {
// if (this.relationMeta?.title) {
// return this.relationMeta.title
// }
// return ''
// },
// parentTable() {
// if (this.rels.includes(this.type)) {
// return this.meta.title
// }
// return ''
// },
// parentColumn() {
// if (this.rels.includes(this.type)) {
// return this.column.title
// }
// return ''
// },
// tooltipMsg() {
// if (!this.column) {
// return ''
// }
// if (this.type === 'hm') {
// return `'${this.parentTable}' has many '${this.childTable}'`
// } else if (this.type === 'mm') {
// return `'${this.childTable}' & '${this.parentTable}' have <br>many to many relation`
// } else if (this.type === 'bt') {
// return `'${this.column.title}' belongs to '${this.childTable}'`
// } else if (this.type === 'lk') {
// return `'${this.childColumn.title}' from '${this.childTable}' (${this.childColumn.uidt})`
// } else if (this.type === 'formula') {
// return `Formula - ${this.column.colOptions.formula}`
// } else if (this.type === 'rl') {
// return `'${this.childColumn.title}' of '${this.childTable}' (${this.childColumn.uidt})`
// }
// return ''
// }
// },
// methods: {
// getMeta(id) {
// return this.$store.state.meta.metas[id] || {}
// },
// async deleteColumn() {
// try {
// await this.$api.dbTableColumn.delete(this.column.id)
//
// if (this.column.uidt === UITypes.LinkToAnotherRecord && this.column.colOptions) {
// this.$store.dispatch('meta/ActLoadMeta', { force: true, id: this.column.colOptions.fk_related_model_id }).then(() => {})
// }
//
// this.$emit('saved')
// this.columnDeleteDialog = false
// } catch (e) {
// this.$toast.error(await this._extractSdkResponseErrorMsg(e)).goAway(3000)
// }
// }
// }
// }
const props = defineProps<{ column: ColumnType & { meta: any } }>()
const column = toRef(column, 'column')
provide(ColumnInj, column)
const { metas } = useMetas()
const meta = inject(MetaInj)
const { isLookup, isBt, isRollup, isMm, isHm, isFormula, isCount } = useVirtualCell(column)
const colOptions = $computed(() => column.value?.colOptions)
const tableTile = $computed(() => meta?.value?.title)
const relationColumnOptions = $computed<LinkToAnotherRecordType | null>(() => {
if (isMm.value || isHm.value || isBt.value) {
return column.value?.colOptions as LinkToAnotherRecordType
} else if ((column?.value?.colOptions as LookupType | RollupType)?.fk_relation_column_id) {
return meta?.value?.columns?.find(
(c) => c.id === (column?.value?.colOptions as LookupType | RollupType)?.fk_relation_column_id,
)?.colOptions as LinkToAnotherRecordType
}
return null
})
const relatedTableMeta = $computed(
() => relationColumnOptions?.fk_related_model_id && metas.value?.[relationColumnOptions?.fk_related_model_id as string],
)
const relatedTableTitle = $computed(() => relatedTableMeta?.title)
const childColumn = $computed(() => {
if (relatedTableMeta?.columns) {
if (isRollup.value) {
const ch = relatedTableMeta?.columns.find((c: ColumnType) => c.id === (colOptions as RollupType).fk_rollup_column_id)
return ch
}
if (isLookup.value) {
const ch = relatedTableMeta?.columns.find((c: ColumnType) => c.id === (colOptions as LookupType).fk_lookup_column_id)
return ch
}
}
return ''
})
const tooltipMsg = computed(() => {
if (!column.value) {
return ''
}
if (isHm.value) {
return `'${tableTile}' has many '${relatedTableTitle}'`
} else if (isMm.value) {
return `'${tableTile}' & '${relatedTableTitle}' have many to many relation`
} else if (isBt.value) {
return `'${column?.value?.title}' belongs to '${relatedTableTitle}'`
} else if (isLookup.value) {
return `'${childColumn.title}' from '${relatedTableTitle}' (${childColumn.uidt})`
} else if (isFormula.value) {
const formula = substituteColumnIdWithAliasInFormula(
(column.value?.colOptions as FormulaType)?.formula,
meta?.value?.columns as ColumnType[],
(column.value?.colOptions as any)?.formula_raw,
)
return `Formula - ${formula}`
} else if (isRollup.value) {
return `'${childColumn.title}' of '${relatedTableTitle}' (${childColumn.uidt})`
}
return ''
})
useProvideColumnCreateStore(meta as Ref<TableType>, column)
</script>
<template>
@ -148,7 +95,13 @@ const isForm = inject(IsFormInj)
todo: bring tooltip
-->
<SmartsheetHeaderVirtualCellIcon v-if="column" />
<span class="name" style="white-space: nowrap" :title="column.title"> {{ column.title }}</span>
<a-tooltip placement="bottom">
<template #title>
{{ tooltipMsg }}
</template>
<span class="name" style="white-space: nowrap" :title="column.title"> {{ column.title }}</span>
</a-tooltip>
<span v-if="column.rqd || required" class="text-red-500">&nbsp;*</span>
<!-- <span class="caption" v-html="tooltipMsg" /> -->

10
packages/nc-gui-v2/components/smartsheet-header/VirtualCellIcon.vue

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import { toRef } from 'vue'
import { ColumnInj } from '~/context'
import GenericIcon from '~icons/mdi/square-rounded'
import HMIcon from '~icons/mdi/table-arrow-right'
@ -11,14 +12,15 @@ import RollupIcon from '~icons/mdi/movie-roll'
import CountIcon from '~icons/mdi/counter'
import SpecificDBTypeIcon from '~icons/mdi/database-settings'
const { columnMeta } = defineProps<{ columnMeta?: ColumnType }>()
const props = defineProps<{ columnMeta?: ColumnType }>()
const columnMeta = toRef(props, 'columnMeta')
const column = inject(ColumnInj, columnMeta)
const column = inject(ColumnInj, ref(columnMeta))
const icon = computed(() => {
switch (column?.uidt) {
switch (column?.value?.uidt) {
case UITypes.LinkToAnotherRecord:
switch ((column?.colOptions as LinkToAnotherRecordType)?.type) {
switch ((column?.value?.colOptions as LinkToAnotherRecordType)?.type) {
case RelationTypes.MANY_TO_MANY:
return MMIcon
case RelationTypes.HAS_MANY:

2
packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilter.vue

@ -124,7 +124,7 @@ defineExpose({
v-if="!filter.readOnly"
:key="i"
small
class="nc-filter-item-remove-btn text-grey"
class="nc-filter-item-remove-btn cursor-pointer text-grey"
@click.stop="deleteFilter(filter, i)"
/>
<span v-else :key="`${i}dummy`" />

31
packages/nc-gui-v2/components/smartsheet-toolbar/ColumnFilterMenu.vue

@ -1,22 +1,33 @@
<script setup lang="ts">
// todo: move to persisted state
import { watchEffect } from '@vue/runtime-core'
import type ColumnFilter from './ColumnFilter.vue'
import { useState } from '#app'
import { IsLockedInj } from '~/context'
import { ActiveViewInj, IsLockedInj } from '~/context'
import MdiFilterIcon from '~icons/mdi/filter-outline'
import MdiMenuDownIcon from '~icons/mdi/menu-down'
const autoApplyFilter = useState('autoApplyFilter', () => false)
const isLocked = inject(IsLockedInj)
const activeView = inject(ActiveViewInj)
// todo: emit from child
const filtersLength = ref(0)
// todo: sync with store
const autosave = ref(true)
const { filterAutoSave } = useGlobal()
// todo: avoid duplicate api call by keeping a filter store
const { filters, loadFilters } = useViewFilters(
activeView,
undefined,
computed(() => false),
)
const filtersLength = ref(0)
watchEffect(async () => {
if (activeView?.value) {
await loadFilters()
filtersLength.value = filters?.value?.length ?? 0
}
})
const filterComp = ref<typeof ColumnFilter>()
// todo: implement
const applyChanges = async () => {
await filterComp?.value?.applyChanges()
}
@ -38,11 +49,11 @@ const applyChanges = async () => {
<SmartsheetToolbarColumnFilter
ref="filterComp"
class="nc-table-toolbar-menu"
:auto-save="autosave"
:auto-save="filterAutoSave"
@update:filters-length="filtersLength = $event"
>
<div class="d-flex align-end mt-2 min-h-[30px]" @click.stop>
<a-checkbox id="col-filter-checkbox" v-model:checked="autosave" class="col-filter-checkbox" hide-details dense>
<a-checkbox id="col-filter-checkbox" v-model:checked="filterAutoSave" class="col-filter-checkbox" hide-details dense>
<span class="text-grey text-xs">
{{ $t('msg.info.filterAutoApply') }}
<!-- Auto apply -->
@ -50,7 +61,7 @@ const applyChanges = async () => {
</a-checkbox>
<div class="flex-1" />
<a-button v-show="!autosave" size="small" class="text-xs ml-2" @click="applyChanges"> Apply changes </a-button>
<a-button v-show="!filterAutoSave" size="small" class="text-xs ml-2" @click="applyChanges"> Apply changes </a-button>
</div>
</SmartsheetToolbarColumnFilter>
</template>

13
packages/nc-gui-v2/components/smartsheet/Cell.vue

@ -16,9 +16,9 @@ interface Emits {
(event: 'update:modelValue', value: any): void
}
const { column, ...props } = defineProps<Props>()
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'save', 'navigate', 'update:editEnabled'])
const column = toRef(props, 'column')
provide(ColumnInj, column)
@ -44,11 +44,11 @@ const isAutoSaved = $computed(() => {
UITypes.AutoNumber,
UITypes.SpecificDBType,
UITypes.Geometry,
].includes(column.uidt as UITypes)
].includes(column?.value?.uidt as UITypes)
})
const isManualSaved = $computed(() => {
return [UITypes.Currency, UITypes.Year, UITypes.Time, UITypes.Duration].includes(column.uidt as UITypes)
return [UITypes.Currency, UITypes.Year, UITypes.Time, UITypes.Duration].includes(column?.value?.uidt as UITypes)
})
const vModel = computed({
@ -92,6 +92,8 @@ const {
} = useColumn(column)
const syncAndNavigate = (dir: NavigateDir) => {
if (isJSON) return
if (changed) {
emit('save')
changed = false
@ -102,7 +104,7 @@ const syncAndNavigate = (dir: NavigateDir) => {
<template>
<div
class="nc-cell"
class="nc-cell w-full-h-full"
@keydown.stop.left
@keydown.stop.right
@keydown.stop.up
@ -130,6 +132,7 @@ const syncAndNavigate = (dir: NavigateDir) => {
<CellFloat v-else-if="isFloat" v-model="vModel" />
<CellText v-else-if="isString" v-model="vModel" />
<CellPercent v-else-if="isPercent" v-model="vModel" />
<CellJson v-else-if="isJSON" v-model="vModel" />
<CellText v-else v-model="vModel" />
</div>
</template>

98
packages/nc-gui-v2/components/smartsheet/Grid.vue

@ -1,8 +1,10 @@
<script lang="ts" setup>
import { onClickOutside, useEventListener } from '@vueuse/core'
import type { ColumnType } from 'nocodb-sdk'
import { isVirtualCol } from 'nocodb-sdk'
import { message } from 'ant-design-vue'
import {
inject,
onKeyStroke,
onMounted,
provide,
useGridViewColumnWidth,
@ -10,10 +12,10 @@ import {
useSmartsheetStoreOrThrow,
useViewData,
} from '#imports'
import type { Row } from '~/composables'
import {
ActiveViewInj,
ChangePageInj,
EditModeInj,
FieldsInj,
IsFormInj,
IsGridInj,
@ -34,11 +36,12 @@ const fields = inject(FieldsInj, ref([]))
const isLocked = inject(IsLockedInj, false)
// todo: get from parent ( inject or use prop )
const isPublicView = false
const isView = false
const selected = reactive<{ row: number | null; col: number | null }>({ row: null, col: null })
let editEnabled = $ref(false)
const { sqlUi } = useProject()
const { xWhere } = useSmartsheetStoreOrThrow()
const { xWhere, isPkAvail } = useSmartsheetStoreOrThrow()
const addColumnDropdown = ref(false)
const contextMenu = ref(false)
const contextMenuTarget = ref(false)
@ -74,12 +77,6 @@ const selectCell = (row: number, col: number) => {
selected.col = col
}
onKeyStroke(['Enter'], (e) => {
if (selected.row !== null && selected.col !== null) {
editEnabled = true
}
})
watch(
() => (view?.value as any)?.id,
async (n?: string, o?: string) => {
@ -103,9 +100,7 @@ defineExpose({
})
// instantiate column create store
// watchEffect(() => {
if (meta) useProvideColumnCreateStore(meta)
// })
// reset context menu target on hide
watch(contextMenu, () => {
@ -127,8 +122,29 @@ const clearCell = async (ctx: { row: number; col: number }) => {
await updateOrSaveRow(rowObj, columnObj.title)
}
const { copy } = useClipboard()
const makeEditable = (row: Row, col: ColumnType) => {
if (isPublicView || editEnabled || isView) {
return
}
if (!isPkAvail.value && !row.rowMeta.new) {
message.info("Update not allowed for table which doesn't have primary Key")
return
}
if (col.ai) {
message.info('Auto Increment field is not editable')
return
}
if (col.pk && !row.rowMeta.new) {
message.info('Editing primary key not supported')
return
}
return (editEnabled = true)
}
/** handle keypress events */
onKeyStroke(['Tab', 'Shift', 'Enter', 'Delete', 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'], async (e: KeyboardEvent) => {
const onKeyDown = async (e: KeyboardEvent) => {
if (selected.row === null || selected.col === null) return
/** on tab key press navigate through cells */
switch (e.key) {
@ -153,7 +169,7 @@ onKeyStroke(['Tab', 'Shift', 'Enter', 'Delete', 'ArrowDown', 'ArrowUp', 'ArrowLe
/** on enter key press make cell editable */
case 'Enter':
e.preventDefault()
editEnabled = true
makeEditable(data.value[selected.row], fields.value[selected.col])
break
/** on delete key press clear cell */
case 'Delete':
@ -177,26 +193,59 @@ onKeyStroke(['Tab', 'Shift', 'Enter', 'Delete', 'ArrowDown', 'ArrowUp', 'ArrowLe
e.preventDefault()
if (selected.row < data.value.length - 1) selected.row++
break
default:
{
const rowObj = data.value[selected.row]
const columnObj = fields.value[selected.col]
if (e.metaKey || e.ctrlKey) {
switch (e.keyCode) {
// copy - ctrl/cmd +c
case 67:
await copy(rowObj.row[columnObj.title] || '')
break
}
}
if (editEnabled || e.ctrlKey || e.altKey || e.metaKey) {
return
}
/** on letter key press make cell editable and empty */
if (e?.key?.length === 1) {
if (!isPkAvail && !rowObj.rowMeta.new) {
return message.info("Update not allowed for table which doesn't have primary Key")
}
if (makeEditable(rowObj, columnObj)) {
rowObj.row[columnObj.title] = ''
}
// editEnabled = true
}
}
break
}
}
useEventListener(document, 'keydown', onKeyDown)
/** On clicking outside of table reset active cell */
const smartTable = ref(null)
onClickOutside(smartTable, () => {
selected.row = null
selected.col = null
})
const onNavigate = (dir: NavigateDir) => {
if (selected.row === null || selected.col === null) return
switch (dir) {
case NavigateDir.NEXT:
if (selected.col < visibleColLength - 1) {
selected.col++
} else if (selected.row < data.value.length - 1) {
if (selected.row < data.value.length - 1) {
selected.row++
selected.col = 0
}
break
case NavigateDir.PREV:
if (selected.col > 0) {
selected.col--
} else if (selected.row > 0) {
if (selected.row > 0) {
selected.row--
selected.col = visibleColLength - 1
}
break
}
@ -207,7 +256,7 @@ const onNavigate = (dir: NavigateDir) => {
<div class="flex flex-col h-100 min-h-0 w-100">
<div class="nc-grid-wrapper min-h-0 flex-1 scrollbar-thin-primary">
<a-dropdown v-model:visible="contextMenu" :trigger="['contextmenu']">
<table class="xc-row-table nc-grid backgroundColorDefault" @contextmenu.prevent="contextMenu = true">
<table ref="smartTable" class="xc-row-table nc-grid backgroundColorDefault" @contextmenu.prevent="contextMenu = true">
<thead>
<tr class="group">
<th>
@ -237,7 +286,7 @@ const onNavigate = (dir: NavigateDir) => {
<!-- v-if="!isLocked && !isVirtual && !isPublicView && _isUIAllowed('add-column')" -->
<th v-t="['c:column:add']" @click="addColumnDropdown = true">
<a-dropdown v-model:visible="addColumnDropdown" :trigger="['click']">
<div class="h-full w-full flex align-center justify-center">
<div class="h-full w-[60px] flex align-center justify-center">
<MdiPlusIcon class="text-sm" />
</div>
<template #overlay>
@ -272,7 +321,7 @@ const onNavigate = (dir: NavigateDir) => {
:data-col="columnObj.id"
:data-title="columnObj.title"
@click="selectCell(rowIndex, colIndex)"
@dblclick="editEnabled = true"
@dblclick="makeEditable(row, columnObj)"
@contextmenu="contextMenuTarget = { row: rowIndex, col: colIndex }"
>
<div class="w-full h-full">
@ -371,7 +420,6 @@ const onNavigate = (dir: NavigateDir) => {
td {
text-overflow: ellipsis;
white-space: nowrap;
}
td.active::after,

8
packages/nc-gui-v2/components/smartsheet/Toolbar.vue

@ -1,3 +1,9 @@
<script setup lang="ts">
import { useSmartsheetStoreOrThrow } from '~/composables'
const { isGrid, isForm } = useSmartsheetStoreOrThrow()
</script>
<template>
<div class="nc-table-toolbar w-full py-1 flex gap-1 items-center" style="z-index: 7">
<SmartsheetToolbarSearchData class="flex-shrink" />
@ -8,7 +14,7 @@
<SmartsheetToolbarSortListMenu />
<SmartsheetToolbarShareView />
<SmartsheetToolbarShareView v-if="isForm || isGrid" />
<SmartsheetToolbarMoreActions />

4
packages/nc-gui-v2/components/smartsheet/VirtualCell.vue

@ -14,12 +14,12 @@ interface Props {
const props = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'navigate'])
const { column, modelValue: value } = props
const column = toRef(props, 'column')
const active = toRef(props, 'active', false)
const row = toRef(props, 'row')
provide(ColumnInj, column)
provide(CellValueInj, value)
provide(ActiveCellInj, active)
provide(RowInj, row)
provide(CellValueInj, toRef(props, 'modelValue'))

8
packages/nc-gui-v2/components/tabs/Smartsheet.vue

@ -37,7 +37,7 @@ provide(ReloadViewDataHookInj, reloadEventHook)
provide(FieldsInj, fields)
provide(RightSidebarInj, ref(true))
useProvideSmartsheetStore(activeView as Ref<TableType>, meta)
const { isGallery, isGrid, isForm } = useProvideSmartsheetStore(activeView as Ref<TableType>, meta)
watch(tabMeta, async (newTabMeta, oldTabMeta) => {
if (newTabMeta !== oldTabMeta && newTabMeta?.id) await getMeta(newTabMeta.id)
@ -52,11 +52,11 @@ watch(tabMeta, async (newTabMeta, oldTabMeta) => {
<template v-if="meta">
<div class="flex flex-1 min-h-0">
<div v-if="activeView" class="h-full flex-grow min-w-0 min-h-0">
<SmartsheetGrid v-if="activeView.type === ViewTypes.GRID" :ref="el" />
<SmartsheetGrid v-if="isGrid" :ref="el" />
<SmartsheetGallery v-else-if="activeView.type === ViewTypes.GALLERY" />
<SmartsheetGallery v-else-if="isGallery" />
<SmartsheetForm v-else-if="activeView.type === ViewTypes.FORM" />
<SmartsheetForm v-else-if="isForm" />
</div>
</div>

5
packages/nc-gui-v2/components/virtual-cell/BelongsTo.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import ItemChip from './components/ItemChip.vue'
import ListItems from './components/ListItems.vue'
import { useProvideLTARStore } from '#imports'
@ -15,7 +16,7 @@ const localState = null
const listItemsDlg = ref(false)
const { relatedTableMeta, loadRelatedTableMeta, relatedTablePrimaryValueProp, unlink } = useProvideLTARStore(
column as Required<ColumnType>,
column as Ref<Required<ColumnType>>,
row,
() => reloadTrigger?.trigger(),
)
@ -29,7 +30,7 @@ await loadRelatedTableMeta()
<ItemChip :item="cellValue" :value="cellValue[relatedTablePrimaryValueProp]" @unlink="unlink(cellValue || localState)" />
</template>
</div>
<div class="flex-1 flex justify-end gap-1">
<div class="flex-1 flex justify-end gap-1 min-h-[30px] align-center">
<MdiExpandIcon
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 select-none group-hover:(text-gray-500)"
@click="listItemsDlg = true"

7
packages/nc-gui-v2/components/virtual-cell/HasMany.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import ItemChip from './components/ItemChip.vue'
import ListChildItems from './components/ListChildItems.vue'
import ListItems from './components/ListItems.vue'
@ -17,7 +18,7 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const { relatedTableMeta, loadRelatedTableMeta, relatedTablePrimaryValueProp, unlink } = useProvideLTARStore(
column as Required<ColumnType>,
column as Ref<Required<ColumnType>>,
row,
() => reloadTrigger?.trigger(),
)
@ -25,14 +26,14 @@ await loadRelatedTableMeta()
</script>
<template>
<div class="flex align-center gap-1 w-full min-full chips-wrapper">
<div class="flex align-center items-center gap-1 w-full chips-wrapper">
<div class="chips flex align-center img-container flex-grow hm-items flex-nowrap min-w-0 overflow-hidden">
<template v-if="cellValue">
<ItemChip v-for="(ch, i) in cellValue" :key="i" :value="ch[relatedTablePrimaryValueProp]" @unlink="unlink(ch)" />
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">more... </span>
</template>
</div>
<div class="flex-grow flex justify-end gap-1">
<div class="flex-grow flex justify-end gap-1 min-h-[30px] align-center">
<MdiExpandIcon
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500"
@click="childListDlg = true"

5
packages/nc-gui-v2/components/virtual-cell/ManyToMany.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import ItemChip from './components/ItemChip.vue'
import ListChildItems from './components/ListChildItems.vue'
import ListItems from './components/ListItems.vue'
@ -17,7 +18,7 @@ const listItemsDlg = ref(false)
const childListDlg = ref(false)
const { relatedTableMeta, loadRelatedTableMeta, relatedTablePrimaryValueProp, unlink } = useProvideLTARStore(
column as Required<ColumnType>,
column as Ref<Required<ColumnType>>,
row,
() => reloadTrigger?.trigger(),
)
@ -35,7 +36,7 @@ await loadRelatedTableMeta()
<span v-if="cellValue?.length === 10" class="caption pointer ml-1 grey--text" @click="childListDlg = true">more... </span>
</template>
</div>
<div class="flex-1 flex justify-end gap-1">
<div class="flex-1 flex justify-end gap-1 min-h-[30px] align-center">
<MdiExpandIcon class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="childListDlg = true" />
<MdiPlusIcon class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500" @click="listItemsDlg = true" />
</div>

11
packages/nc-gui-v2/composables/useColumn.ts

@ -1,16 +1,17 @@
import type { ColumnType } from 'nocodb-sdk'
import { SqlUiFactory, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useProject } from '#imports'
export function useColumn(column: ColumnType) {
export function useColumn(column: Ref<ColumnType>) {
const { project } = useProject()
const uiDatatype: UITypes = (column && column.uidt) as UITypes
const abstractType = isVirtualCol(column)
const uiDatatype: UITypes = column?.value?.uidt as UITypes
const abstractType = isVirtualCol(column?.value)
? null
: SqlUiFactory.create(project.value?.bases?.[0]?.config || { client: 'mysql2' }).getAbstractType(column)
: SqlUiFactory.create(project.value?.bases?.[0]?.config || { client: 'mysql2' }).getAbstractType(column?.value)
const dataTypeLow = column && column.dt && column.dt.toLowerCase()
const dataTypeLow = column?.value?.dt?.toLowerCase()
const isBoolean = abstractType === 'boolean'
const isString = abstractType === 'string'
const isTextArea = uiDatatype === UITypes.LongText

351
packages/nc-gui-v2/composables/useColumnCreateStore.ts

@ -20,204 +20,207 @@ const useForm = Form.useForm
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState((meta: Ref<TableType>, column?: ColumnType) => {
const { sqlUi } = useProject()
const { $api } = useNuxtApp()
const [useProvideColumnCreateStore, useColumnCreateStore] = createInjectionState(
(meta: Ref<TableType>, column?: Ref<ColumnType>) => {
const { sqlUi } = useProject()
const { $api } = useNuxtApp()
const toast = useToast()
const idType = null
// state
// todo: give proper type - ColumnType
const formState = ref<Partial<Record<string, any>>>({
title: 'title',
uidt: UITypes.SingleLineText,
...(column?.value || {}),
// todo: swagger json update - include meta
meta: (column?.value as any)?.meta || {},
})
const additionalValidations = ref<Record<string, any>>({})
const validators = computed(() => {
return {
column_name: [
{
required: true,
message: 'Column name is required',
},
// validation for unique column name
{
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
if (
meta.value?.columns?.some(
(c) =>
c.id !== formState.value.id && // ignore current column
// compare against column_name and title
((value || '').toLowerCase() === (c.column_name || '').toLowerCase() ||
(value || '').toLowerCase() === (c.title || '').toLowerCase()),
)
) {
return reject(new Error('Duplicate column name'))
}
resolve()
})
},
},
],
uidt: [
{
required: true,
message: 'UI Datatype is required',
},
],
...(additionalValidations?.value || {}),
}
})
const toast = useToast()
const { resetFields, validate, validateInfos } = useForm(formState, validators)
const idType = null
const setAdditionalValidations = (validations: Record<string, any>) => {
additionalValidations.value = validations
}
// state
// todo: give proper type - ColumnType
const formState = ref<Partial<Record<string, any>>>({
title: 'title',
uidt: UITypes.SingleLineText,
...(column || {}),
// todo: swagger json update - include meta
meta: (column as any)?.meta || {},
})
// actions
const generateNewColumnMeta = () => {
setAdditionalValidations({})
formState.value = { meta: {}, ...sqlUi.value.getNewColumn((meta.value?.columns?.length || 0) + 1) }
}
const additionalValidations = ref<Record<string, any>>({})
const onUidtOrIdTypeChange = () => {
const { isCurrency } = useColumn(ref(formState.value as ColumnType))
const colProp = sqlUi?.value.getDataTypeForUiType(formState?.value as any, idType as any)
formState.value = {
...formState.value,
meta: {},
rqd: false,
pk: false,
ai: false,
cdf: null,
un: false,
dtx: 'specificType',
...colProp,
}
const validators = computed(() => {
return {
column_name: [
{
required: true,
message: 'Column name is required',
},
// validation for unique column name
{
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
if (
meta.value?.columns?.some(
(c) =>
c.id !== formState.value.id && // ignore current column
// compare against column_name and title
((value || '').toLowerCase() === (c.column_name || '').toLowerCase() ||
(value || '').toLowerCase() === (c.title || '').toLowerCase()),
)
) {
return reject(new Error('Duplicate column name'))
}
resolve()
})
},
},
],
uidt: [
{
required: true,
message: 'UI Datatype is required',
},
],
...(additionalValidations?.value || {}),
}
})
const { resetFields, validate, validateInfos } = useForm(formState, validators)
const setAdditionalValidations = (validations: Record<string, any>) => {
additionalValidations.value = validations
}
// actions
const generateNewColumnMeta = () => {
setAdditionalValidations({})
formState.value = { meta: {}, ...sqlUi.value.getNewColumn((meta.value?.columns?.length || 0) + 1) }
}
const onUidtOrIdTypeChange = () => {
const { isCurrency } = useColumn(formState.value as ColumnType)
const colProp = sqlUi?.value.getDataTypeForUiType(formState?.value as any, idType as any)
formState.value = {
...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)
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
}
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.uidt as UITypes)) {
formState.value.dtxp = column.dtxp
}
if (columnToValidate.includes(formState.value.uidt)) {
formState.value.meta = {
validate: formState.value.meta && formState.value.meta.validate,
}
}
if (columnToValidate.includes(formState.value.uidt)) {
formState.value.meta = {
validate: formState.value.meta && formState.value.meta.validate,
if (isCurrency) {
if (column?.value?.uidt === UITypes.Currency) {
formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs
} else {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
}
formState.value.altered = formState.value.altered || 2
}
if (isCurrency) {
if (column?.uidt === UITypes.Currency) {
formState.value.dtxp = column.dtxp
formState.value.dtxs = column.dtxs
} else {
formState.value.dtxp = 19
formState.value.dtxs = 2
const onDataTypeChange = () => {
const { isCurrency } = useColumn(ref(formState.value as ColumnType))
formState.value.rqd = false
if (formState.value.uidt !== UITypes.ID) {
formState.value.primaryKey = false
}
}
formState.value.ai = false
formState.value.cdf = null
formState.value.un = false
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt)
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt)
formState.value.altered = formState.value.altered || 2
}
formState.value.dtx = 'specificType'
const onDataTypeChange = () => {
const { isCurrency } = useColumn(formState.value as ColumnType)
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column?.value && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column?.value.uidt as UITypes)) {
formState.value.dtxp = column?.value.dtxp
}
formState.value.rqd = false
if (formState.value.uidt !== UITypes.ID) {
formState.value.primaryKey = false
}
formState.value.ai = false
formState.value.cdf = null
formState.value.un = false
formState.value.dtxp = sqlUi.value.getDefaultLengthForDatatype(formState.value.dt)
formState.value.dtxs = sqlUi.value.getDefaultScaleForDatatype(formState.value.dt)
if (isCurrency) {
if (column?.value?.uidt === UITypes.Currency) {
formState.value.dtxp = column.value.dtxp
formState.value.dtxs = column.value.dtxs
} else {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
}
formState.value.dtx = 'specificType'
// this.$set(formState.value, 'uidt', sqlUi.value.getUIType(formState.value));
const selectTypes = [UITypes.MultiSelect, UITypes.SingleSelect]
if (column && selectTypes.includes(formState.value.uidt) && selectTypes.includes(column.uidt as UITypes)) {
formState.value.dtxp = column.dtxp
formState.value.altered = formState.value.altered || 2
}
if (isCurrency) {
if (column?.uidt === UITypes.Currency) {
formState.value.dtxp = column.dtxp
formState.value.dtxs = column.dtxs
} else {
formState.value.dtxp = 19
formState.value.dtxs = 2
}
const onAlter = (val = 2, cdf = false) => {
formState.value.altered = formState.value.altered || val
if (cdf) formState.value.cdf = formState.value.cdf || null
}
// this.$set(formState.value, 'uidt', sqlUi.value.getUIType(formState.value));
formState.value.altered = formState.value.altered || 2
}
const onAlter = (val = 2, cdf = false) => {
formState.value.altered = formState.value.altered || val
if (cdf) formState.value.cdf = formState.value.cdf || null
}
const addOrUpdate = async (onSuccess?: () => void) => {
try {
console.log(formState, validators)
if (!(await validate())) return
formState.value.table_name = meta.value.table_name
formState.value.title = formState.value.column_name
if (column) {
await $api.dbTableColumn.update(column.id as string, formState.value)
toast.success('Column updated')
} else {
// todo : set additional meta for auto generated string id
if (formState.value.uidt === UITypes.ID) {
// based on id column type set autogenerated meta prop
// if (isAutoGenId) {
// this.newColumn.meta = {
// ag: 'nc',
// };
// }
const addOrUpdate = async (onSuccess: () => {}) => {
try {
console.log(formState, validators)
if (!(await validate())) return
formState.value.table_name = meta.value.table_name
formState.value.title = formState.value.column_name
if (column?.value) {
await $api.dbTableColumn.update(column?.value?.id as string, formState.value)
toast.success('Column updated')
} else {
// todo : set additional meta for auto generated string id
if (formState.value.uidt === UITypes.ID) {
// based on id column type set autogenerated meta prop
// if (isAutoGenId) {
// this.newColumn.meta = {
// ag: 'nc',
// };
// }
}
await $api.dbTableColumn.create(meta.value.id as string, formState.value)
toast.success('Column created')
}
await $api.dbTableColumn.create(meta.value.id as string, formState.value)
toast.success('Column created')
onSuccess?.()
} catch (e: any) {
const error = await extractSdkResponseErrorMsg(e)
if (error) toast.error(await extractSdkResponseErrorMsg(e))
}
onSuccess?.()
} catch (e: any) {
const error = await extractSdkResponseErrorMsg(e)
if (error) toast.error(await extractSdkResponseErrorMsg(e))
}
}
return {
formState,
resetFields,
validate,
validateInfos,
setAdditionalValidations,
onUidtOrIdTypeChange,
sqlUi,
onDataTypeChange,
onAlter,
addOrUpdate,
generateNewColumnMeta,
isEdit: !!column?.id,
}
})
return {
formState,
resetFields,
validate,
validateInfos,
setAdditionalValidations,
onUidtOrIdTypeChange,
sqlUi,
onDataTypeChange,
onAlter,
addOrUpdate,
generateNewColumnMeta,
isEdit: computed(() => !!column?.value?.id),
column,
}
},
)
export { useProvideColumnCreateStore }

3
packages/nc-gui-v2/composables/useGlobal/state.ts

@ -56,10 +56,11 @@ export function useGlobalState(storageKey = 'nocodb-gui-v2'): State {
createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(),
isHidden: false,
},
filterAutoSave: true,
}
/** saves a reactive state, any change to these values will write/delete to localStorage */
const storage = useStorage<StoredState>(storageKey, initialState)
const storage = useStorage<StoredState>(storageKey, initialState, localStorage, { mergeDefaults: true })
/** force turn off of dark mode, regardless of previously stored settings */
storage.value.darkMode = false

1
packages/nc-gui-v2/composables/useGlobal/types.ts

@ -17,6 +17,7 @@ export interface StoredState {
lang: string
darkMode: boolean
feedbackForm: FeedbackForm
filterAutoSave: boolean
}
export type State = ToRefs<Omit<StoredState, 'token'>> & {

18
packages/nc-gui-v2/composables/useLTARStore.ts

@ -13,7 +13,7 @@ interface DataApiResponse {
/** Store for managing Link to another cells */
const [useProvideLTARStore, useLTARStore] = useInjectionState(
(column: Required<ColumnType>, row?: Ref<Row>, reloadData = () => {}) => {
(column: Ref<Required<ColumnType>>, row?: Ref<Row>, reloadData = () => {}) => {
// state
const { metas, getMeta } = useMetas()
const { project } = useProject()
@ -31,12 +31,12 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
size: 10,
})
const colOptions = column.colOptions as LinkToAnotherRecordType
const colOptions = $computed(() => column?.value.colOptions as LinkToAnotherRecordType)
// getters
const meta = computed(() => metas?.value?.[column.fk_model_id as string])
const meta = computed(() => metas?.value?.[column?.value?.fk_model_id as string])
const relatedTableMeta = computed<TableType>(() => {
return metas.value?.[(column.colOptions as any)?.fk_related_model_id as string]
return metas.value?.[colOptions?.fk_related_model_id as string]
})
const rowId = computed(() =>
@ -72,8 +72,8 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
project.value.id as string,
meta.value.id,
rowId.value,
(column.colOptions as LinkToAnotherRecordType).type as 'mm' | 'hm',
column.title,
colOptions.type as 'mm' | 'hm',
column?.value?.title,
// todo: swagger type correction
{
limit: childrenExcludedListPagination.size,
@ -99,7 +99,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
meta.value.id,
rowId.value,
colOptions.type as 'mm' | 'hm',
column.title,
column?.value?.title,
// todo: swagger type correction
{
limit: childrenListPagination.size,
@ -157,7 +157,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
meta.value.title,
rowId.value,
colOptions.type as 'mm' | 'hm',
column.title,
column?.value?.title,
getRelatedTableRowId(row) as string,
)
} catch (e) {
@ -195,7 +195,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
meta.value.title as string,
rowId.value,
colOptions.type as 'mm' | 'hm',
column.title,
column?.value?.title,
getRelatedTableRowId(row) as string,
)
} catch (e) {

15
packages/nc-gui-v2/composables/useSmartsheetStore.ts

@ -1,11 +1,12 @@
import { computed } from '@vue/reactivity'
import { createInjectionState } from '@vueuse/core'
import { ViewTypes } from 'nocodb-sdk'
import type { TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { useNuxtApp } from '#app'
import { useProject } from '#imports'
import { useInjectionState } from '~/composables/useInjectionState'
const [useProvideSmartsheetStore, useSmartsheetStore] = createInjectionState((view: Ref<ViewType>, meta: Ref<TableType>) => {
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState((view: Ref<ViewType>, meta: Ref<TableType>) => {
const { $api } = useNuxtApp()
const { sqlUi } = useProject()
@ -18,6 +19,10 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = createInjectionState((vi
// getters
const isLocked = computed(() => (view?.value as any)?.lock_type === 'locked')
const isPkAvail = computed(() => meta?.value?.columns?.some((c) => c.pk))
const isGrid = computed(() => (view?.value as any)?.type === ViewTypes.GRID)
const isForm = computed(() => (view?.value as any)?.type === ViewTypes.FORM)
const isGallery = computed(() => (view?.value as any)?.type === ViewTypes.GALLERY)
const xWhere = computed(() => {
let where
const col = meta?.value?.columns?.find(({ id }) => id === search.field) || meta?.value?.columns?.find((v) => v.pv)
@ -41,8 +46,12 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = createInjectionState((vi
$api,
search,
xWhere,
isPkAvail,
isForm,
isGrid,
isGallery,
}
})
}, 'smartsheet-store')
export { useProvideSmartsheetStore }

15
packages/nc-gui-v2/composables/useTabs.ts

@ -18,15 +18,15 @@ export interface TabItem {
function getPredicate(key: Partial<TabItem>) {
return (tab: TabItem) =>
(!('id' in key) || tab.id === key.id) &&
(!('title' in key) || tab.title === key.id) &&
(!('type' in key) || tab.type === key.id)
(!('title' in key) || tab.title === key.title) &&
(!('type' in key) || tab.type === key.type)
}
export function useTabs() {
const tabs = useState<TabItem[]>('tabs', () => [])
const route = useRoute()
const router = useRouter()
const { tables } = useProject()
const activeTabIndex: WritableComputedRef<number> = computed({
@ -36,6 +36,8 @@ export function useTabs() {
const id = tables.value?.find((t) => t.title === tab.title)?.id
if (!id) return -1
tab.id = id as string
let index = tabs.value.findIndex((t) => t.id === tab.id)
@ -108,7 +110,14 @@ export function useTabs() {
const updateTab = (key: number | Partial<TabItem>, newTabItemProps: Partial<TabItem>) => {
const tab = typeof key === 'number' ? tabs.value[key] : tabs.value.find(getPredicate(key))
if (tab) {
const isActive = tabs.value.indexOf(tab) === activeTabIndex.value
Object.assign(tab, newTabItemProps)
if (isActive && tab.title)
router.replace({
params: {
title: tab.title,
},
})
}
}

12
packages/nc-gui-v2/composables/useViewColumns.ts

@ -115,10 +115,12 @@ export function useViewColumns(
},
set(v) {
if (view?.value?.id) {
$api.dbView.update(view.value.id, {
// todo: update swagger
show_system_fields: v,
} as any)
$api.dbView
.update(view.value.id, {
// todo: update swagger
show_system_fields: v,
} as any)
.finally(() => reloadData?.())
;(view.value as any).show_system_fields = v
}
},
@ -149,7 +151,7 @@ export function useViewColumns(
) {
return false
}
return c.show
return c.show && metaColumnById?.value?.[c.fk_column_id!]
})
?.sort((a, b) => a.order - b.order)
?.map((c) => metaColumnById?.value?.[c.fk_column_id!]) || []) as ColumnType[]

17
packages/nc-gui-v2/composables/useViewData.ts

@ -41,6 +41,16 @@ export function useViewData(
},
})
const syncCount = async () => {
const { count } = await $api.dbViewRow.count(
NOCO,
project?.value?.title as string,
meta?.value?.id as string,
viewMeta?.value?.id as string,
)
paginationData.value.totalRows = count
}
const loadData = async (params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) => {
if (!project?.value?.id || !meta?.value?.id || !viewMeta?.value?.id) return
const response = await $api.dbViewRow.list('noco', project.value.id, meta.value.id, viewMeta.value.id, {
@ -72,6 +82,7 @@ export function useViewData(
rowMeta: {},
oldRow: { ...insertedData },
})
syncCount()
} catch (error: any) {
notification.error({
message: 'Row insert failed',
@ -87,7 +98,7 @@ export function useViewData(
.map((c) => row[c.title as string])
.join('___') as string
return $api.dbViewRow.update(
return await $api.dbViewRow.update(
NOCO,
project?.value.id as string,
meta?.value.id as string,
@ -169,7 +180,6 @@ export function useViewData(
})
return false
}
return true
}
@ -188,6 +198,7 @@ export function useViewData(
}
}
formattedData.value.splice(rowIndex, 1)
syncCount()
} catch (e: any) {
notification.error({
message: 'Failed to delete row',
@ -223,6 +234,7 @@ export function useViewData(
})
}
}
syncCount()
}
const loadFormView = async () => {
@ -284,6 +296,7 @@ export function useViewData(
deleteSelectedRows,
updateOrSaveRow,
selectedAllRecords,
syncCount,
loadFormView,
formColumnData,
formViewData,

27
packages/nc-gui-v2/composables/useViewFilters.ts

@ -43,19 +43,24 @@ export function useViewFilters(
}
const deleteFilter = async (filter: FilterType & { status: string }, i: number) => {
// if shared or sync permission not allowed simply remove it from array
if (shared || !isUIAllowed('filterSync')) {
const _filters = unref(filters.value)
_filters.splice(i, 1)
filters.value = _filters
} else if (filter.id) {
if (!autoApply?.value) {
filter.status = 'delete'
filters.value.splice(i, 1)
reloadData?.()
} else {
if (filter.id) {
// if auto-apply disabled mark it as disabled
if (!autoApply?.value) {
filter.status = 'delete'
// if auto-apply enabled invoke delete api and remove from array
} else {
await $api.dbTableFilter.delete(filter.id)
reloadData?.()
filters.value.splice(i, 1)
}
// if not synced yet remove it from array
} else {
await $api.dbTableFilter.delete(filter.id)
const _filters = unref(filters.value)
_filters.splice(i, 1)
filters.value = _filters
reloadData?.()
filters.value.splice(i, 1)
}
}
}

22
packages/nc-gui-v2/composables/useVirtualCell.ts

@ -1,26 +1,28 @@
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { computed } from '#imports'
export function useVirtualCell(column: ColumnType) {
export function useVirtualCell(column: Ref<ColumnType>) {
const isHm = computed(
() =>
column.uidt === UITypes.LinkToAnotherRecord && (<LinkToAnotherRecordType>column.colOptions).type === RelationTypes.HAS_MANY,
column?.value?.uidt === UITypes.LinkToAnotherRecord &&
(<LinkToAnotherRecordType>column?.value?.colOptions).type === RelationTypes.HAS_MANY,
)
const isMm = computed(
() =>
column.uidt === UITypes.LinkToAnotherRecord &&
(<LinkToAnotherRecordType>column.colOptions).type === RelationTypes.MANY_TO_MANY,
column?.value?.uidt === UITypes.LinkToAnotherRecord &&
(<LinkToAnotherRecordType>column?.value?.colOptions).type === RelationTypes.MANY_TO_MANY,
)
const isBt = computed(
() =>
column.uidt === UITypes.LinkToAnotherRecord &&
(<LinkToAnotherRecordType>column.colOptions).type === RelationTypes.BELONGS_TO,
column?.value?.uidt === UITypes.LinkToAnotherRecord &&
(<LinkToAnotherRecordType>column?.value?.colOptions).type === RelationTypes.BELONGS_TO,
)
const isLookup = computed(() => column.uidt === UITypes.Lookup)
const isRollup = computed(() => column.uidt === UITypes.Rollup)
const isFormula = computed(() => column.uidt === UITypes.Formula)
const isCount = computed(() => column.uidt === UITypes.Count)
const isLookup = computed(() => column?.value?.uidt === UITypes.Lookup)
const isRollup = computed(() => column?.value?.uidt === UITypes.Rollup)
const isFormula = computed(() => column?.value?.uidt === UITypes.Formula)
const isCount = computed(() => column?.value?.uidt === UITypes.Count)
return {
isHm,

2
packages/nc-gui-v2/context/index.ts

@ -8,7 +8,7 @@ import type { TabItem } from '~/composables/useTabs'
export const EditEnabledInj: InjectionKey<boolean> = Symbol('edit-enabled')
export const ActiveCellInj: InjectionKey<Ref<boolean>> = Symbol('active-cell')
export const RowInj: InjectionKey<Ref<Row>> = Symbol('row')
export const ColumnInj: InjectionKey<ColumnType & { meta: any }> = Symbol('column-injection')
export const ColumnInj: InjectionKey<Ref<ColumnType & { meta: any }>> = Symbol('column-injection')
export const MetaInj: InjectionKey<ComputedRef<TableType>> = Symbol('meta-injection')
export const TabMetaInj: InjectionKey<ComputedRef<TabItem>> = Symbol('tab-meta-injection')
export const PaginationDataInj: InjectionKey<ReturnType<typeof useViewData>['paginationData']> =

33
packages/nc-gui-v2/package-lock.json generated

@ -11,6 +11,7 @@
"ant-design-vue": "^3.2.10",
"dayjs": "^1.11.3",
"file-saver": "^2.0.5",
"jsep": "^1.3.6",
"jwt-decode": "^3.1.2",
"locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0",
@ -27,6 +28,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "^0.25.2",
"@iconify-json/cil": "^1.1.2",
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
"@iconify-json/ic": "^1.1.7",
@ -989,6 +991,15 @@
"dev": true,
"peer": true
},
"node_modules/@iconify-json/cil": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz",
"integrity": "sha512-fu9x1f+A2H5qGWnApU1aw0EREAKqg5EP2Z6cWHV11XchlKgzY+jWQCalctkV+Jsef2M2m3C2DX/ukgyhMclIcw==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/clarity": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@iconify-json/clarity/-/clarity-1.1.4.tgz",
@ -8785,6 +8796,14 @@
"node": ">=12"
}
},
"node_modules/jsep": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w==",
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/jsesc": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
@ -15493,6 +15512,15 @@
"dev": true,
"peer": true
},
"@iconify-json/cil": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/cil/-/cil-1.1.2.tgz",
"integrity": "sha512-fu9x1f+A2H5qGWnApU1aw0EREAKqg5EP2Z6cWHV11XchlKgzY+jWQCalctkV+Jsef2M2m3C2DX/ukgyhMclIcw==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify-json/clarity": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@iconify-json/clarity/-/clarity-1.1.4.tgz",
@ -21278,6 +21306,11 @@
}
}
},
"jsep": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.3.6.tgz",
"integrity": "sha512-o7fP1eZVROIChADx7HKiwGRVI0tUqgUUGhaok6DP7cMxpDeparuooREDBDeNk2G5KIB49MBSkRYsCOu4PmZ+1w=="
},
"jsesc": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",

2
packages/nc-gui-v2/package.json

@ -17,6 +17,7 @@
"ant-design-vue": "^3.2.10",
"dayjs": "^1.11.3",
"file-saver": "^2.0.5",
"jsep": "^1.3.6",
"jwt-decode": "^3.1.2",
"locale-codes": "^1.3.1",
"monaco-editor": "^0.33.0",
@ -33,6 +34,7 @@
},
"devDependencies": {
"@antfu/eslint-config": "^0.25.2",
"@iconify-json/cil": "^1.1.2",
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
"@iconify-json/ic": "^1.1.7",

3
packages/nc-gui-v2/pages/nc/[projectId]/index.vue

@ -4,9 +4,10 @@ import { TabType } from '~/composables'
const route = useRoute()
const { loadProject, loadTables } = useProject(route.params.projectId as string)
const { addTab } = useTabs()
const { addTab, clearTabs } = useTabs()
const { $state } = useNuxtApp()
clearTabs()
if (!route.params.type) {
addTab({ type: TabType.AUTH, title: 'Team & Auth' })
}

4
packages/nc-gui-v2/pages/nc/[projectId]/index/index.vue

@ -162,4 +162,8 @@ function openQuickImportDialog(type: string) {
:deep(.ant-menu-submenu::after) {
@apply !border-none;
}
:deep(.ant-tabs-nav-add) {
@apply !hidden;
}
</style>

87
packages/nc-gui-v2/utils/NcAutocompleteTree.ts

@ -0,0 +1,87 @@
// ref : https://medium.com/weekly-webtips/js-implementing-auto-complete-f4c5a5d5c009
interface Node {
value: []
isLeaf: boolean
children: Record<string, Node>
}
export class NcAutocompleteTree {
trie: Record<string, any>
suggestions: Record<string, any>[]
constructor() {
this.trie = {}
this.suggestions = []
}
newNode(): Node {
return {
value: [],
isLeaf: false,
children: {},
}
}
add(word: Record<string, any>) {
if (Object.keys(this.trie).length === 0) {
this.trie = this.newNode()
}
let root = this.trie
for (const letter of word.text.toLowerCase()) {
if (!(letter in root.children)) {
root.children[letter] = this.newNode()
}
root = root.children[letter]
}
root.value = root.value || []
root.value.push(word)
}
find(word: string) {
let root = this.trie
for (const letter of word) {
if (letter in root.children) {
root = root.children[letter]
} else {
return null // if not found return null
}
}
return root // return the root where it ends search
}
traverse(root: Node) {
if (root.value && root.value.length) {
this.suggestions.push(...root.value)
}
for (const letter in root.children) {
this.traverse(root.children[letter])
}
}
complete(word: string, CHILDREN = null) {
this.suggestions = []
const root = this.find(word.toLowerCase())
if (!root) {
return this.suggestions
} // cannot suggest anything
const children = root.children
let spread = 0
for (const letter in children) {
this.traverse(children[letter])
spread++
if (CHILDREN && spread === CHILDREN) {
break
}
}
return this.suggestions
}
}

10
packages/nc-gui-v2/utils/columnUtils.ts

@ -25,6 +25,8 @@ import MathIntegral from '~icons/mdi/math-integral'
import MovieRoll from '~icons/mdi/movie-roll'
import Counter from '~icons/mdi/counter'
import CalendarClock from '~icons/mdi/calendar-clock'
import ID from '~icons/mdi/identifier'
import RulerSquareCompass from '~icons/mdi/ruler-square-compass'
const uiTypes = [
{
@ -133,7 +135,7 @@ const uiTypes = [
},
{
name: UITypes.Geometry,
icon: 'mdi-ruler-square-compass',
icon: RulerSquareCompass,
},
{
name: UITypes.JSON,
@ -151,15 +153,15 @@ const getUIDTIcon = (uidt: UITypes) => {
...uiTypes,
{
name: UITypes.CreateTime,
icon: 'mdi-calendar-clock',
icon: CalendarClock,
},
{
name: UITypes.ID,
icon: 'mdi-identifier',
icon: ID,
},
{
name: UITypes.ForeignKey,
icon: 'mdi-link-variant',
icon: LinkVariant,
},
].find((t) => t.name === uidt) || {}
).icon

14
packages/nc-gui-v2/utils/dateTimeUtils.ts

@ -1,4 +1,6 @@
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat)
export const timeAgo = (date: any) => {
return dayjs.utc(date).fromNow()
@ -30,3 +32,15 @@ export const handleTZ = (val: any) => {
},
)
}
export function validateDateFormat(v: string) {
return dateFormats.includes(v)
}
export function validateDateWithUnknownFormat(v: string) {
let res = 0
for (const format of dateFormats) {
res |= dayjs(v, format, true).isValid() as any
}
return res
}

448
packages/nc-gui-v2/utils/formulaUtils.ts

@ -0,0 +1,448 @@
import type { Input as AntInput } from 'ant-design-vue'
const formulaTypes = {
NUMERIC: 'numeric',
STRING: 'string',
DATE: 'date',
LOGICAL: 'logical',
COND_EXP: 'conditional_expression',
}
const formulas: Record<string, any> = {
AVG: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Average of input parameters',
syntax: 'AVG(value1, [value2, ...])',
examples: ['AVG(10, 5) => 7.5', 'AVG({column1}, {column2})', 'AVG({column1}, {column2}, {column3})'],
},
ADD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Sum of input parameters',
syntax: 'ADD(value1, [value2, ...])',
examples: ['ADD(5, 5) => 10', 'ADD({column1}, {column2})', 'ADD({column1}, {column2}, {column3})'],
},
DATEADD: {
type: formulaTypes.DATE,
validation: {
args: {
rqd: 3,
},
},
description: 'Adds a "count" units to Datetime.',
syntax: 'DATEADD(date | datetime, value, ["day" | "week" | "month" | "year"])',
examples: [
'DATEADD({column1}, 2, "day")',
'DATEADD({column1}, -2, "day")',
'DATEADD({column1}, 2, "week")',
'DATEADD({column1}, -2, "week")',
'DATEADD({column1}, 2, "month")',
'DATEADD({column1}, -2, "month")',
'DATEADD({column1}, 2, "year")',
'DATEADD({column1}, -2, "year")',
],
},
AND: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 1,
},
},
description: 'TRUE if all expr evaluate to TRUE',
syntax: 'AND(expr1, [expr2, ...])',
examples: ['AND(5 > 2, 5 < 10) => 1', 'AND({column1} > 2, {column2} < 10)'],
},
OR: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 1,
},
},
description: 'TRUE if at least one expr evaluates to TRUE',
syntax: 'OR(expr1, [expr2, ...])',
examples: ['OR(5 > 2, 5 < 10) => 1', 'OR({column1} > 2, {column2} < 10)'],
},
CONCAT: {
type: formulaTypes.STRING,
validation: {
args: {
min: 1,
},
},
description: 'Concatenated string of input parameters',
syntax: 'CONCAT(str1, [str2, ...])',
examples: ['CONCAT("AA", "BB", "CC") => "AABBCC"', 'CONCAT({column1}, {column2}, {column3})'],
},
TRIM: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Remove trailing and leading whitespaces from input parameter',
syntax: 'TRIM(str)',
examples: ['TRIM(" HELLO WORLD ") => "HELLO WORLD"', 'TRIM({column1})'],
},
UPPER: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Upper case converted string of input parameter',
syntax: 'UPPER(str)',
examples: ['UPPER("nocodb") => "NOCODB"', 'UPPER({column1})'],
},
LOWER: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Lower case converted string of input parameter',
syntax: 'LOWER(str)',
examples: ['LOWER("NOCODB") => "nocodb"', 'LOWER({column1})'],
},
LEN: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Input parameter character length',
syntax: 'LEN(value)',
examples: ['LEN("NocoDB") => 6', 'LEN({column1})'],
},
MIN: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Minimum value amongst input parameters',
syntax: 'MIN(value1, [value2, ...])',
examples: ['MIN(1000, 2000) => 1000', 'MIN({column1}, {column2})'],
},
MAX: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
},
},
description: 'Maximum value amongst input parameters',
syntax: 'MAX(value1, [value2, ...])',
examples: ['MAX(1000, 2000) => 2000', 'MAX({column1}, {column2})'],
},
CEILING: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Rounded next largest integer value of input parameter',
syntax: 'CEILING(value)',
examples: ['CEILING(1.01) => 2', 'CEILING({column1})'],
},
FLOOR: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Rounded largest integer less than or equal to input parameter',
syntax: 'FLOOR(value)',
examples: ['FLOOR(3.1415) => 3', 'FLOOR({column1})'],
},
ROUND: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Nearest integer to the input parameter',
syntax: 'ROUND(value)',
examples: ['ROUND(3.1415) => 3', 'ROUND({column1})'],
},
MOD: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 2,
},
},
description: 'Remainder after integer division of input parameters',
syntax: 'MOD(value1, value2)',
examples: ['MOD(1024, 1000) => 24', 'MOD({column}, 2)'],
},
REPEAT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Specified copies of the input parameter string concatenated together',
syntax: 'REPEAT(str, count)',
examples: ['REPEAT("A", 5) => "AAAAA"', 'REPEAT({column}, 5)'],
},
LOG: {
type: formulaTypes.NUMERIC,
validation: {},
description: 'Logarithm of input parameter to the base (default = e) specified',
syntax: 'LOG([base], value)',
examples: ['LOG(2, 1024) => 10', 'LOG(2, {column1})'],
},
EXP: {
type: formulaTypes.NUMERIC,
validation: {},
description: 'Exponential value of input parameter (e ^ power)',
syntax: 'EXP(power)',
examples: ['EXP(1) => 2.718281828459045', 'EXP({column1})'],
},
POWER: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 2,
},
},
description: 'base to the exponent power, as in base ^ exponent',
syntax: 'POWER(base, exponent)',
examples: ['POWER(2, 10) => 1024', 'POWER({column1}, 10)'],
},
SQRT: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Square root of the input parameter',
syntax: 'SQRT(value)',
examples: ['SQRT(100) => 10', 'SQRT({column1})'],
},
ABS: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Absolute value of the input parameter',
syntax: 'ABS(value)',
examples: ['ABS({column1})'],
},
NOW: {
type: formulaTypes.DATE,
validation: {
args: {
rqd: 0,
},
},
description: 'Returns the current time and day',
syntax: 'NOW()',
examples: ['NOW() => 2022-05-19 17:20:43'],
},
REPLACE: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'String, after replacing all occurrences of srchStr with rplcStr',
syntax: 'REPLACE(str, srchStr, rplcStr)',
examples: ['REPLACE("AABBCC", "AA", "BB") => "BBBBCC"', 'REPLACE({column1}, {column2}, {column3})'],
},
SEARCH: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'Index of srchStr specified if found, 0 otherwise',
syntax: 'SEARCH(str, srchStr)',
examples: ['SEARCH("HELLO WORLD", "WORLD") => 7', 'SEARCH({column1}, "abc")'],
},
INT: {
type: formulaTypes.NUMERIC,
validation: {
args: {
rqd: 1,
},
},
description: 'Integer value of input parameter',
syntax: 'INT(value)',
examples: ['INT(3.1415) => 3', 'INT({column1})'],
},
RIGHT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'n characters from the end of input parameter',
syntax: 'RIGHT(str, n)',
examples: ['RIGHT("HELLO WORLD", 5) => WORLD', 'RIGHT({column1}, 3)'],
},
LEFT: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 2,
},
},
description: 'n characters from the beginning of input parameter',
syntax: 'LEFT(str, n)',
examples: ['LEFT({column1}, 2)', 'LEFT("ABCD", 2) => "AB"'],
},
SUBSTR: {
type: formulaTypes.STRING,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'Substring of length n of input string from the postition specified',
syntax: ' SUBTR(str, position, [n])',
examples: ['SUBSTR("HELLO WORLD", 7) => WORLD', 'SUBSTR("HELLO WORLD", 7, 3) => WOR', 'SUBSTR({column1}, 7, 5)'],
},
MID: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 3,
},
},
description: 'Alias for SUBSTR',
syntax: 'MID(str, position, [count])',
examples: ['MID("NocoDB", 3, 2) => "co"', 'MID({column1}, 3, 2)'],
},
IF: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 2,
max: 3,
},
},
description: 'SuccessCase if expr evaluates to TRUE, elseCase otherwise',
syntax: 'IF(expr, successCase, elseCase)',
examples: ['IF(5 > 1, "YES", "NO") => "YES"', 'IF({column} > 1, "YES", "NO")'],
},
SWITCH: {
type: formulaTypes.COND_EXP,
validation: {
args: {
min: 3,
},
},
description: 'Switch case value based on expr output',
syntax: 'SWITCH(expr, [pattern, value, ..., default])',
examples: [
'SWITCH(1, 1, "One", 2, "Two", "N/A") => "One""',
'SWITCH(2, 1, "One", 2, "Two", "N/A") => "Two"',
'SWITCH(3, 1, "One", 2, "Two", "N/A") => "N/A"',
'SWITCH({column1}, 1, "One", 2, "Two", "N/A")',
],
},
URL: {
type: formulaTypes.STRING,
validation: {
args: {
rqd: 1,
},
},
description: 'Convert to a hyperlink if it is a valid URL',
syntax: 'URL(str)',
examples: ['URL("https://github.com/nocodb/nocodb")', 'URL({column1})'],
},
WEEKDAY: {
type: formulaTypes.NUMERIC,
validation: {
args: {
min: 1,
max: 2,
},
},
description: 'Returns the day of the week as an integer between 0 and 6 inclusive starting from Monday by default',
syntax: 'WEEKDAY(date, [startDayOfWeek])',
examples: ['WEEKDAY("2021-06-09")', 'WEEKDAY(NOW(), "sunday")'],
},
}
const formulaList = Object.keys(formulas)
// ref : https://stackoverflow.com/a/11077016
function insertAtCursor(myField: typeof AntInput, myValue: string, len = 0, b = 0) {
// MOZILLA and others
if (myField.selectionStart || myField.selectionStart === 0) {
const startPos = myField.selectionStart
const endPos = myField.selectionEnd
myField.value = myField.value.substring(0, startPos - len) + myValue + myField.value.substring(endPos, myField.value.length)
const pos = +startPos - len + myValue.length - b
// https://stackoverflow.com/a/4302688
if (myField.setSelectionRange) {
myField.focus()
myField.setSelectionRange(pos, pos)
} else if (myField.createTextRange) {
const range = myField.createTextRange()
range.collapse(true)
range.moveEnd('character', pos)
range.moveStart('character', pos)
range.select()
}
} else {
myField.value += myValue
}
return myField.value
}
function ReturnWord(text: string, caretPos: number) {
const preText = text.substring(0, caretPos)
if (preText.indexOf(' ') > 0) {
const words = preText.split(' ')
return words[words.length - 1] // return last word
} else {
return preText
}
}
function getWordUntilCaret(ctrl: typeof AntInput) {
const caretPos = GetCaretPosition(ctrl)
const word = ReturnWord(ctrl.value, caretPos)
return word || ''
}
function GetCaretPosition(ctrl: typeof AntInput) {
let CaretPos = 0
if (ctrl.selectionStart || ctrl.selectionStart === 0) {
CaretPos = ctrl.selectionStart
}
return CaretPos
}
export { formulaList, formulas, formulaTypes, getWordUntilCaret, insertAtCursor }

3
packages/nc-gui-v2/utils/index.ts

@ -1,6 +1,8 @@
export * from './NcAutocompleteTree'
export * from './colorsUtils'
export * from './dateTimeUtils'
export * from './deepCompare'
export * from './formulaUtils'
export * from './durationUtils'
export * from './errorUtils'
export * from './fileUtils'
@ -9,6 +11,7 @@ export * from './filterUtils'
export * from './generateName'
export * from './projectCreateUtils'
export * from './urlUtils'
export * from './columnUtils'
export * from './validation'
export * from './viewUtils'
export * from './currencyUtils'

5
scripts/sdk/swagger.json

@ -6778,7 +6778,7 @@
}
},
"Formula": {
"title": "Lookup",
"title": "Formula",
"type": "object",
"properties": {
"id": {
@ -6796,6 +6796,9 @@
"formula": {
"type": "string"
},
"formula_raw": {
"type": "string"
},
"deleted": {
"type": "string"
},

Loading…
Cancel
Save