Merge pull request #10927 from nocodb/develop

This commit is contained in:
github-actions[bot]
2025-03-20 11:17:27 +00:00
committed by GitHub
407 changed files with 12065 additions and 6808 deletions

View File

@@ -110,7 +110,7 @@ stdenv.mkDerivation (finalAttrs: {
pnpmDeps = pnpm.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-6zSF/1GjQu+H5ERJue+KEm06rFhJxmVM2eq2RPKg5Y4=";
hash = "sha256-6G1vbje6sgLfPxGat40v90uJX1fLkE0mlR9BFuwYxMo=";
};
meta = {

View File

@@ -985,7 +985,12 @@ svg.nc-virtual-cell-icon {
}
}
.ant-switch {
background: #E7E7E9 !important;
}
.ant-switch-checked {
@apply !bg-nc-fill-primary;
&.nc-ai-input {
@apply !bg-nc-fill-purple-medium;
}

View File

@@ -34,21 +34,25 @@ const formRules = {
}
const passwordChange = async () => {
const valid = formValidator.value.validate()
if (!valid) return
try {
const valid = formValidator.value.validate()
if (!valid) return
error.value = null
error.value = null
await api.auth.passwordChange({
currentPassword: form.currentPassword,
newPassword: form.password,
})
await api.auth.passwordChange({
currentPassword: form.currentPassword,
newPassword: form.password,
})
message.success(t('msg.success.passwordChanged'))
message.success(t('msg.success.passwordChanged'))
await signOut({
redirectToSignin: true,
})
await signOut({
redirectToSignin: true,
})
} catch {
// ignore since error value is set by useApi and will be displayed in UI
}
}
const resetError = () => {

View File

@@ -23,7 +23,7 @@ const _vModel = useVModel(props, 'modelValue', emit)
const lastSaved = ref()
const cellFocused = ref(false)
const inputType = computed(() => (!isForm.value && !cellFocused.value ? 'text' : 'number'))
const inputType = computed(() => (isExpandedFormOpen.value && !cellFocused.value ? 'text' : 'number'))
const currencyMeta = computed(() => {
return {

View File

@@ -29,7 +29,7 @@ const _vModel = useVModel(props, 'modelValue', emit)
const cellFocused = ref(false)
const inputType = computed(() => (!isForm.value && !cellFocused.value ? 'text' : 'number'))
const inputType = computed(() => (isExpandedFormOpen.value && !cellFocused.value ? 'text' : 'number'))
const vModel = computed({
get: () => _vModel.value,

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { isDateMonthFormat, isSystemColumn } from 'nocodb-sdk'
import { parseFlexibleDate } from '~/utils/datetimeUtils'
interface Props {
modelValue?: string | null
@@ -30,7 +31,7 @@ const editable = inject(EditModeInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const isForm = inject(IsFormInj, ref(false))
@@ -52,7 +53,7 @@ const open = ref<boolean>(false)
const tempDate = ref<dayjs.Dayjs | undefined>()
const canvasCellEventData = inject(CanvasCellEventDataInj)!
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const localState = computed({
get() {
@@ -61,6 +62,11 @@ const localState = computed({
}
if (!dayjs(modelValue).isValid()) {
const parsedDate = parseFlexibleDate(modelValue)
if (parsedDate) {
return parsedDate
}
isDateInvalid.value = true
return undefined
}
@@ -144,9 +150,8 @@ onClickOutside(datePickerRef, (e) => {
const onBlur = (e) => {
const value = (e?.target as HTMLInputElement)?.value
if (value && dayjs(value).isValid()) {
handleUpdateValue(e, true, dayjs(dayjs(value).format(dateFormat.value)))
if (value && dayjs(value, dateFormat.value).isValid()) {
handleUpdateValue(e, true)
}
if (

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { isDateMonthFormat } from 'nocodb-sdk'
import { parseFlexibleDate } from '~/utils/datetimeUtils'
interface Props {
modelValue?: string | null
@@ -20,6 +21,10 @@ const localState = computed(() => {
}
if (!dayjs(modelValue).isValid()) {
const parsedDate = parseFlexibleDate(modelValue)
if (parsedDate) {
return parsedDate
}
return undefined
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import dayjs from 'dayjs'
import { isDateMonthFormat, isSystemColumn } from 'nocodb-sdk'
import { parseFlexibleDate } from '~/utils/datetimeUtils'
interface Props {
modelValue?: string | null
@@ -57,6 +58,10 @@ const localState = computed({
}
if (!dayjs(modelValue).isValid()) {
const parsedDate = parseFlexibleDate(modelValue)
if (parsedDate) {
return parsedDate
}
isDateInvalid.value = true
return undefined
}

View File

@@ -28,12 +28,12 @@ const editable = inject(EditModeInj, ref(false))
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const canvasCellEventData = inject(CanvasCellEventDataInj)!
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))

View File

@@ -0,0 +1,220 @@
<script lang="ts" setup>
import { composeNewDecimalValue, ncIsNaN } from 'nocodb-sdk'
import type { StyleValue } from 'vue'
interface Props {
placeholder?: string
inputStyle?: StyleValue
modelValue?: number | null
disabled?: boolean
precision?: number
isFocusOnMounted?: boolean
}
interface Emits {
(event: 'update:modelValue', model: number): void
(event: 'blur', model: FocusEvent): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'modelValue', emits)
const inputRef = templateRef('input-ref')
const pasteText = (target: HTMLInputElement, value: string) => {
if (!value || value === '') {
return { changed: false }
}
const selectionEnd = target.selectionEnd
const lastValue = target.value
const newValue = composeNewDecimalValue({
selectionStart: target.selectionStart,
selectionEnd: target.selectionEnd,
lastValue,
newValue: value,
})
if (target.value !== newValue) {
target.value = newValue
}
if (selectionEnd || selectionEnd === 0) {
const newCursorIndex = target.value.length - (lastValue.length - selectionEnd)
target.setSelectionRange(newCursorIndex, newCursorIndex)
}
}
const refreshVModel = () => {
if (inputRef.value && vModel.value) {
if (typeof vModel.value === 'number') {
if (props.precision) {
inputRef.value.value = vModel.value.toFixed(props.precision) ?? ''
} else {
inputRef.value.value = vModel.value.toString()
}
} else if (typeof vModel.value === 'string') {
const numberValue = Number(vModel.value)
if (!ncIsNaN(numberValue)) {
if (props.precision) {
inputRef.value.value = numberValue.toFixed(props.precision) ?? ''
} else {
inputRef.value.value = numberValue.toString()
}
}
}
}
}
const saveValue = (targetValue: string) => {
if (targetValue === '') {
vModel.value = null
return
}
const value = Number(targetValue)
if (ncIsNaN(value)) {
vModel.value = null
return
}
vModel.value = value
}
let savingHandle: any
const onInputKeyUp = (e: KeyboardEvent) => {
const target: HTMLInputElement = e.target as HTMLInputElement
if (target) {
// mac's double space insert period
// not perfect, but better
if (target.value.match(/\.\s/)?.[0]) {
target.value = target.value.replace(/\.\s/, '')
}
// debounce, maybe there's some helpers in vue?
if (savingHandle) {
clearTimeout(savingHandle)
}
savingHandle = setTimeout(() => {
saveValue(target.value)
}, 100)
}
}
// Handle the arrow keys as its default behavior is to increment/decrement the value
const onInputKeyDown = (e: KeyboardEvent) => {
const target: HTMLInputElement = e.target as HTMLInputElement
if (!target) {
return
}
const functionKeys = ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']
if (
[
'ArrowLeft',
'ArrowRight',
'Enter',
'Escape',
'Home',
'End',
'PageUp',
'PageDown',
'Delete',
'Backspace',
'Tab',
...functionKeys,
].includes(e.key) ||
e.ctrlKey ||
e.altKey ||
e.metaKey
) {
return
}
if (e.key === 'ArrowDown') {
target.setSelectionRange(target.value.length, target.value.length)
return
} else if (e.key === 'ArrowUp') {
target.setSelectionRange(0, 0)
return
} else if (e.key.match('[^-0-9\.]')) {
// prevent everything non ctrl / alt and non . and non number
e.preventDefault()
e.stopPropagation()
return
}
pasteText(target, e.key)
e.preventDefault()
e.stopPropagation()
}
const onInputPaste = (e: ClipboardEvent) => {
if (e.clipboardData === null || typeof e.clipboardData === 'undefined') {
return
}
const target: HTMLInputElement = e.target as HTMLInputElement
if (!target) {
return
}
const value = e.clipboardData.getData('text/plain')
if (value === null || value === '' || typeof value === 'undefined') {
return
}
e.preventDefault()
e.stopPropagation()
pasteText(target, value)
}
const onInputBlur = (e: FocusEvent) => {
emits('blur', e)
if (e.target) {
const targetValue = (e.target as HTMLInputElement).value
saveValue(targetValue)
setTimeout(() => {
// allow for debouncing to clear first
refreshVModel()
}, 100)
}
}
const registerEvents = (input: HTMLInputElement) => {
input.addEventListener('keydown', onInputKeyDown)
input.addEventListener('keyup', onInputKeyUp)
input.addEventListener('paste', onInputPaste)
input.addEventListener('blur', onInputBlur)
}
onMounted(() => {
if (inputRef.value) {
registerEvents(inputRef.value as HTMLInputElement)
refreshVModel()
if (props.isFocusOnMounted) {
inputRef.value.focus()
}
}
})
</script>
<template>
<!-- eslint-disable vue/use-v-on-exact -->
<input
ref="input-ref"
class="nc-cell-field outline-none rounded-md"
:placeholder="placeholder"
style="letter-spacing: 0.06rem; height: 24px !important"
:style="inputStyle"
:disabled="disabled"
@keydown.left.stop
@keydown.right.stop
@keydown.delete.stop
@keydown.alt.stop
@selectstart.capture.stop
@mousedown.stop
/>
</template>
<style scoped lang="scss">
input[type='number']:focus {
@apply ring-transparent;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
</style>

View File

@@ -1,11 +1,9 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
interface Props {
// when we set a number, then it is number type
// for sqlite, when we clear a cell or empty the cell, it returns ""
// otherwise, it is null type
modelValue?: number | null | string
modelValue?: number | null
placeholder?: string
}
@@ -23,53 +21,19 @@ const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const inputRef = ref<HTMLInputElement>()
const _vModel = useVModel(props, 'modelValue', emits)
const vModel = computed({
get: () => _vModel.value,
set: (value) => {
if (value === '') {
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null
} else {
_vModel.value = value
}
},
})
const vModel = useVModel(props, 'modelValue', emits)
const precision = computed(() => {
const meta = typeof column?.value.meta === 'string' ? JSON.parse(column.value.meta) : column?.value.meta ?? {}
const _precision = meta.precision ?? 1
return Number(0.1 ** _precision).toFixed(_precision)
return parseProp(column?.value.meta).precision ?? 1
})
// Handle the arrow keys as its default behavior is to increment/decrement the value
const onKeyDown = (e: any) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
// Move the cursor to the end of the input
e.target.type = 'text'
e.target?.setSelectionRange(e.target.value.length, e.target.value.length)
e.target.type = 'number'
} else if (e.key === 'ArrowUp') {
e.preventDefault()
e.target.type = 'text'
e.target?.setSelectionRange(0, 0)
e.target.type = 'number'
}
}
const focus: VNodeRef = (el) => {
if (!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value) {
inputRef.value = el as HTMLInputElement
inputRef.value?.focus()
}
}
onMounted(() => {
if (canvasCellEventData?.keyboardKey && isSinglePrintableKey(canvasCellEventData?.keyboardKey)) {
vModel.value = Number(canvasCellEventData.keyboardKey)
}
if (isCanvasInjected && !isExpandedFormOpen.value && !isEditColumn.value && !isForm.value) {
inputRef.value?.focus()
}
@@ -78,41 +42,16 @@ onMounted(() => {
<template>
<!-- eslint-disable vue/use-v-on-exact -->
<input
:ref="focus"
<CellDecimalInput
v-model="vModel"
class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full"
type="number"
:step="precision"
:placeholder="placeholder"
style="letter-spacing: 0.06rem"
:is-focus-on-mounted="!isExpandedFormOpen && !isEditColumn && !isForm"
:style="{
...(!isForm && !isExpandedFormOpen && { 'margin-top': '1px' }),
...((isForm || isExpandedFormOpen) && { width: '100%' }),
}"
:disabled="readOnly"
:precision="precision"
@blur="editEnabled = false"
@keydown.down.stop="onKeyDown"
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop="onKeyDown"
@keydown.delete.stop
@keydown.alt.stop
@selectstart.capture.stop
@mousedown.stop
/>
</template>
<style scoped lang="scss">
input[type='number']:focus {
@apply ring-transparent;
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
}
</style>

View File

@@ -21,12 +21,13 @@ const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const localState = ref(props.modelValue)
const inputRef = ref<HTMLInputElement>()
const isFocused = ref(false)
const vModel = computed({
get: () => props.modelValue,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isForm.value || isEditColumn.value) {
emit('update:modelValue', val)
}
},
@@ -56,7 +57,10 @@ onBeforeUnmount(() => {
localState.value &&
!validateEmail(localState.value)
) {
message.error(t('msg.error.invalidEmail'))
if (!isEditColumn.value) {
message.error(t('msg.error.invalidEmail'))
}
localState.value = undefined
return
}
@@ -68,16 +72,32 @@ onMounted(() => {
inputRef.value?.focus()
}
})
const onBlur = () => {
editEnabled.value = false
isFocused.value = false
}
const validEmail = computed(() => vModel.value && validateEmail(vModel.value))
const showClicableLink = computed(() => {
return (isExpandedFormOpen.value || isForm.value) && !isFocused.value && validEmail.value
})
</script>
<template>
<!-- eslint-disable vue/use-v-on-exact -->
<input
v-bind="$attrs"
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full outline-none py-1"
:class="{
'!text-transparent': showClicableLink,
}"
:disabled="readOnly"
@blur="editEnabled = false"
@blur="onBlur"
@focus="isFocused = true"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@@ -88,4 +108,18 @@ onMounted(() => {
@mousedown.stop
@paste.prevent="onPaste"
/>
<div
v-if="showClicableLink"
class="nc-cell-field absolute inset-0 flex items-center max-w-full overflow-hidden pointer-events-none"
>
<nuxt-link
no-ref
class="truncate text-primary cursor-pointer pointer-events-auto no-user-select"
:href="`mailto:${vModel}`"
target="_blank"
:tabindex="-1"
>
{{ vModel }}
</nuxt-link>
</div>
</template>

View File

@@ -35,7 +35,7 @@ const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isForm.value || isEditColumn.value) {
emit('update:modelValue', val)
}
},

View File

@@ -20,7 +20,12 @@ const { showNull } = useGlobal()
const editEnabled = inject(EditModeInj, ref(false))
const active = inject(ActiveCellInj, ref(false))
const canvasSelectCell = inject(CanvasSelectCellInj)
const cellEventHook = inject(CellEventHookInj, null)
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const isEditColumn = inject(EditColumnInj, ref(false))
@@ -43,7 +48,7 @@ const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const formatValue = (val: ModelValueType) => {
return !val || val === 'null' ? null : val
return val ?? null
}
const localValue = computed<ModelValueType>({
@@ -197,11 +202,27 @@ watch(inputWrapperRef, () => {
}
})
const onCellEvent = (event?: Event) => {
if (!(event instanceof KeyboardEvent) || !event.target || isActiveInputElementExist(event)) return
if (isExpandCellKey(event)) {
if (isExpanded.value) {
closeJSONEditor()
} else {
openJSONEditor()
}
return true
}
}
const el = useCurrentElement()
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
onMounted(() => {
cellEventHook?.on(onCellEvent)
if (
!isUnderLookup.value &&
isCanvasInjected &&
@@ -211,6 +232,8 @@ onMounted(() => {
!isExpandedFormOpen.value
) {
forcedNextTick(() => {
if (onCellEvent(canvasCellEventData.event)) return
openJSONEditor()
})
}
@@ -225,6 +248,8 @@ onMounted(() => {
})
onUnmounted(() => {
cellEventHook?.off(onCellEvent)
const gridCell = el.value?.closest?.('td')
if (gridCell && !readOnly.value) {
gridCell.removeEventListener('dblclick', openJSONEditor)
@@ -245,7 +270,7 @@ onUnmounted(() => {
:footer="null"
:wrap-class-name="isExpanded ? '!z-1051 nc-json-expanded-modal' : null"
class="relative"
:class="{ 'json-modal min-w-80': isExpanded }"
:class="{ 'json-modal min-w-80': isExpanded, 'min-h-6 flex items-center': !isExpanded }"
>
<div v-if="isExpanded" class="flex flex-col w-full" @mousedown.stop @mouseup.stop @click.stop>
<div class="flex flex-row justify-between items-center -mt-2 pb-3 nc-json-action" @mousedown.stop>
@@ -264,7 +289,7 @@ onUnmounted(() => {
<LazyMonacoEditor
ref="inputWrapperRef"
:model-value="localValue || ''"
:model-value="localValue ?? null"
class="min-w-full w-[40rem] resize overflow-auto expanded-editor"
:hide-minimap="true"
:disable-deep-compare="true"
@@ -279,10 +304,15 @@ onUnmounted(() => {
{{ error.toString() }}
</span>
</div>
<span v-else-if="vModel === null && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText v-else :value="vModel ? stringifyProp(vModel) : ''" :lines="rowHeight" class="nc-cell-field" />
<NcTooltip placement="bottom" class="nc-json-expand-btn hidden absolute top-0 right-0">
<template #title>{{ $t('title.expand') }}</template>
<span v-else-if="ncIsNull(vModel) && showNull" class="nc-cell-field nc-null uppercase">{{ $t('general.null') }}</span>
<LazyCellClampedText
v-else
:value="!ncIsUndefined(vModel) && !ncIsNull(vModel) ? stringifyProp(vModel) : ''"
:lines="rowHeight"
class="nc-cell-field"
/>
<NcTooltip placement="bottom" class="nc-json-expand-btn hidden absolute top-0 bottom-0 right-0">
<template #title>{{ isExpandedFormOpen ? $t('title.expand') : $t('tooltip.expandShiftSpace') }}</template>
<NcButton type="secondary" size="xsmall" class="!w-5 !h-5 !min-w-[fit-content]" @click.stop="openJSONEditor">
<component :is="iconMap.maximize" class="w-3 h-3" />
</NcButton>
@@ -302,7 +332,7 @@ onUnmounted(() => {
<style lang="scss">
.nc-cell-json:hover .nc-json-expand-btn,
.nc-grid-cell:hover .nc-json-expand-btn {
@apply block;
@apply flex items-center;
}
.nc-default-value-wrapper .nc-cell-json,
.nc-grid-cell .nc-cell-json {
@@ -313,13 +343,17 @@ onUnmounted(() => {
}
.nc-expand-col-JSON.nc-expanded-form-row .nc-cell-json {
min-height: 34px;
@apply !flex items-center max-w-full;
& > div {
@apply !max-w-full w-full;
}
}
.nc-default-value-wrapper,
.nc-expanded-cell,
.ant-form-item-control-input {
.nc-json-expand-btn {
@apply !block;
@apply flex items-center;
}
}
</style>

View File

@@ -44,7 +44,7 @@ const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const isFocusing = ref(false)
@@ -323,7 +323,7 @@ watch(
},
)
const canvasCellEventData = inject(CanvasCellEventDataInj)!
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))

View File

@@ -10,6 +10,7 @@ interface Props {
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue'])
const col = inject(ColumnInj)
const editEnabled = inject(EditModeInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
@@ -23,7 +24,7 @@ const cellFocused = ref(false)
const inputRef = ref<HTMLInputElement>()
const focus: VNodeRef = (el) => {
if ((!isExpandedFormOpen.value || localEditEnabled.value) && !isEditColumn.value && !isForm.value) {
if ((!isExpandedFormOpen.value || localEditEnabled.value) && !isEditColumn.value) {
inputRef.value = el as HTMLInputElement
if (cellFocused.value) return
@@ -31,7 +32,7 @@ const focus: VNodeRef = (el) => {
if (isExpandedFormOpen.value) {
inputRef.value?.focus()
inputRef.value?.select()
} else {
} else if (!isForm.value) {
inputRef.value?.focus()
}
}
@@ -53,21 +54,28 @@ const vModel = computed({
}
},
})
const vModelNumber = computed<number>(() => {
if (_vModel.value && _vModel.value !== '' && !isNaN(Number(_vModel.value))) {
return Number(_vModel.value)
}
return 0
})
const inputType = computed(() => (isForm.value && !isEditColumn.value ? 'text' : 'number'))
const onBlur = () => {
editEnabled.value = false
cellFocused.value = false
localEditEnabled.value = false
if (isExpandedFormOpen.value) {
editEnabled.value = false
cellFocused.value = false
localEditEnabled.value = false
}
}
const onFocus = () => {
cellFocused.value = true
}
onMounted(() => {
if (isCanvasInjected && (!isExpandedFormOpen.value || localEditEnabled.value) && !isEditColumn.value && !isForm.value) {
if (isCanvasInjected || (!isEditColumn.value && !isForm.value)) {
inputRef.value?.focus()
if (isExpandedFormOpen.value) {
inputRef.value?.select()
@@ -77,25 +85,56 @@ onMounted(() => {
</script>
<template>
<!-- eslint-disable vue/use-v-on-exact -->
<input
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1"
:type="inputType"
:placeholder="placeholder"
:disabled="readOnly"
@blur="onBlur"
@focus="onFocus"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.alt.stop
@selectstart.capture.stop
@mousedown.stop
/>
<CellPercentProgressBar
v-if="parseProp(col!.meta).is_progress && (isForm)"
:style="{
...(isForm && { 'min-height': '22px', 'height': '22px' }),
}"
:is-show-number="true"
:percentage="vModelNumber"
>
<!-- eslint-disable vue/use-v-on-exact -->
<input
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full !border-none !outline-none focus:ring-0 h-full min-h-[18px]"
:class="isExpandedFormOpen ? 'py-1' : ''"
:type="inputType"
:placeholder="placeholder"
:disabled="readOnly"
@blur="onBlur"
@focus="onFocus"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.alt.stop
@selectstart.capture.stop
@mousedown.stop
/>
</CellPercentProgressBar>
<div v-else>
<!-- eslint-disable vue/use-v-on-exact -->
<input
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1"
:type="inputType"
:placeholder="placeholder"
:disabled="readOnly"
@blur="onBlur"
@focus="onFocus"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@keydown.up.stop
@keydown.delete.stop
@keydown.alt.stop
@selectstart.capture.stop
@mousedown.stop
/>
</div>
</template>
<style lang="scss" scoped>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
interface Props {
percentage: number
isShowNumber?: boolean
}
const props = defineProps<Props>()
const cPercentage = computed(() => Math.max(0, Math.min(100, props.percentage)))
const labelMarginLeft = computed<number>(() => {
return Math.max(1, Math.min(props.percentage / 2, 50))
})
const slots = useSlots()
const slotHasChildren = (name?: string) => {
return (slots[name ?? 'default']?.()?.length ?? 0) > 0
}
</script>
<template>
<div
class="flex flex-col w-full progress-container min-h-[4px]"
style="align-self: stretch; justify-self: stretch; height: 100%; border-radius: 9999px"
>
<div class="progress-bar-input" :class="slotHasChildren() ? 'has-child' : ''">
<slot></slot>
</div>
<div class="w-full progress-bar flex" style="align-self: stretch; border-radius: 9999px; overflow: hidden; height: 100%">
<div style="align-self: stretch; background-color: #3366ff" :style="{ width: `${cPercentage}%` }"></div>
<div style="align-self: stretch; background-color: #e5e5e5" :style="{ width: `${100 - cPercentage}%` }"></div>
<template v-if="isShowNumber">
<div style="position: absolute" :style="{ 'margin-left': `${labelMarginLeft}%` }">
<span
style="mix-blend-mode: difference; color: #ffffff"
:style="{
'margin-left': `${-Math.min(percentage, 50)}%`,
}"
>
{{ `${percentage}%` }}
</span>
</div>
<div style="position: absolute" :style="{ 'margin-left': `${labelMarginLeft}%` }">
<span
style="mix-blend-mode: overlay; color: #ffffff"
:style="{
'margin-left': `${-Math.min(percentage, 50)}%`,
}"
>
{{ `${percentage}%` }}
</span>
</div>
</template>
</div>
</div>
</template>
<style lang="scss" scoped>
.progress-container:not(:focus-within):not(:hover) > div.progress-bar-input:not(:focus-within):not(:hover) {
position: absolute;
top: 0px;
max-height: 0px !important;
overflow-y: hidden;
}
.progress-container:focus-within > div.progress-bar-input.has-child,
.progress-container:hover > div.progress-bar-input.has-child {
position: relative;
width: 100%;
max-height: 100%;
height: 100%;
overflow-y: hidden;
transition: max-height 0.1s ease-in;
}
.progress-container:focus-within:has(div.progress-bar-input.has-child) > div.progress-bar,
.progress-container:hover:has(div.progress-bar-input.has-child) > div.progress-bar {
visibility: collapse;
opacity: 0;
display: none;
transition: visibility 0.1s ease-out, opacity 0.1s ease-out, display 0.1s allow-discrete;
}
</style>

View File

@@ -23,6 +23,12 @@ const expandedEditEnabled = ref(false)
const percentValue = computed(() => {
return props.modelValue && !isNaN(Number(props.modelValue)) ? `${props.modelValue}%` : props.modelValue
})
const percentValueNumber = computed(() => {
if (props.modelValue && props.modelValue !== '' && !isNaN(Number(props.modelValue))) {
return Number(props.modelValue)
}
return 0
})
const percentMeta = computed(() => {
return {
@@ -62,6 +68,31 @@ const progressPercent = computed(() => {
<template>
<div
v-if="(column.meta as any).is_progress"
class="nc-cell-field w-full flex py-1"
:style="{
...(!isExpandedFormOpen && { height: '4px' }),
...(isExpandedFormOpen && { height: '100%' }),
}"
style="min-height: 4px"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
@focus="onWrapperFocus"
@click="onWrapperFocus"
>
<CellPercentProgressBar :percentage="percentValueNumber" :is-show-number="isExpandedFormOpen">
<template v-if="!readOnly" #default>
<input
class="w-full !border-none !outline-none focus:ring-0 min-h-[10px]"
:value="modelValue"
@click="onWrapperFocus"
@focus="onWrapperFocus"
/>
</template>
</CellPercentProgressBar>
</div>
<div
v-else
:tabindex="readOnly ? -1 : 0"
class="nc-filter-value-select w-full focus:outline-transparent relative z-3"
:class="readOnly ? 'cursor-not-allowed pointer-events-none' : ''"
@@ -84,3 +115,9 @@ const progressPercent = computed(() => {
<span v-else class="nc-cell-field">{{ percentValue ? percentValue : '&nbsp;' }} </span>
</div>
</template>
<style lang="scss">
.nc-cell:has(.progress-container) {
height: 100% !important;
}
</style>

View File

@@ -21,12 +21,13 @@ const isCanvasInjected = inject(IsCanvasInjectionInj, false)
// Used in the logic of when to display error since we are not storing the phone if it's not valid
const localState = ref(props.modelValue)
const inputRef = ref<HTMLInputElement>()
const isFocused = ref(false)
const vModel = computed({
get: () => props.modelValue,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && isMobilePhone(val)) || !val || isForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && isMobilePhone(val)) || !val || isForm.value || isEditColumn.value) {
emit('update:modelValue', val)
}
},
@@ -41,8 +42,11 @@ const focus: VNodeRef = (el) => {
}
onBeforeUnmount(() => {
if (parseProp(column.value.meta)?.validate && localState.value && !isMobilePhone(localState.value)) {
message.error(t('msg.invalidPhoneNumber'))
if (parseProp(column.value.meta)?.validate && localState.value && !isMobilePhone(localState.value.toString())) {
if (!isEditColumn.value) {
message.error(t('msg.invalidPhoneNumber'))
}
localState.value = undefined
return
}
@@ -54,16 +58,32 @@ onMounted(() => {
inputRef.value?.focus()
}
})
const onBlur = () => {
editEnabled.value = false
isFocused.value = false
}
const validPhoneNumber = computed(() => vModel.value && isMobilePhone(vModel.value.toString()))
const showClicableLink = computed(() => {
return (isExpandedFormOpen.value || isForm.value) && !isFocused.value && validPhoneNumber.value
})
</script>
<template>
<!-- eslint-disable vue/use-v-on-exact -->
<input
v-bind="$attrs"
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full outline-none py-1"
:class="{
'!text-transparent': showClicableLink,
}"
:disabled="readOnly"
@blur="editEnabled = false"
@blur="onBlur"
@focus="isFocused = true"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@@ -73,4 +93,19 @@ onMounted(() => {
@selectstart.capture.stop
@mousedown.stop
/>
<div
v-if="showClicableLink"
class="nc-cell-field absolute inset-0 flex items-center max-w-full overflow-hidden pointer-events-none"
>
<a
no-ref
class="truncate text-primary cursor-pointer pointer-events-auto no-user-select tracking-tighter"
:href="`tel:${vModel}`"
target="_blank"
rel="noopener noreferrer"
:tabindex="-1"
>
{{ vModel }}
</a>
</div>
</template>

View File

@@ -49,8 +49,14 @@ const focus: VNodeRef = (el) =>
watch(
() => editEnabled.value,
() => {
if (parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !isMobilePhone(localState.value)) {
message.error(t('msg.invalidPhoneNumber'))
if (
(parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !isMobilePhone(localState.value)) ||
isEditColumn.value
) {
if (!isEditColumn.value) {
message.error(t('msg.invalidPhoneNumber'))
}
localState.value = undefined
return
}

View File

@@ -34,7 +34,7 @@ const active = computed(() => activeCell.value || isEditable.value || isForm.val
const aselect = ref<typeof AntSelect>()
const isOpen = ref(false)
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const isKanban = inject(IsKanbanInj, ref(false))
@@ -274,7 +274,7 @@ watch(
},
)
const canvasCellEventData = inject(CanvasCellEventDataInj)!
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))

View File

@@ -14,6 +14,8 @@ const props = defineProps<{
const emits = defineEmits(['update:modelValue', 'update:isAiEdited', 'generate', 'close'])
const STORAGE_KEY = 'nc-long-text-expanded-modal-size'
const meta = inject(MetaInj, ref())
const column = inject(ColumnInj)
@@ -36,12 +38,15 @@ const readOnlyInj = inject(ReadonlyInj, ref(false))
const isUnderFormula = inject(IsUnderFormulaInj, ref(false))
const cellEventHook = inject(CellEventHookInj, null)
const readOnly = computed(() => readOnlyInj.value || column.value.readonly)
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const clientMousePosition = inject(ClientMousePositionInj)
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const { showNull, user } = useGlobal()
@@ -288,27 +293,78 @@ const handleClose = () => {
isVisible.value = false
}
const STORAGE_KEY = 'nc-long-text-expanded-modal-size'
const { width: widthTextArea, height: heightTextArea } = useElementSize(inputRef)
watch([widthTextArea, heightTextArea], () => {
if (isVisible.value) {
const size = {
width: widthTextArea.value,
height: heightTextArea.value,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(size))
watch(textAreaRef, (el) => {
if (el && !isExpandedFormOpen.value && !isEditColumn.value && !isForm.value) {
el.focus()
}
})
const onCellEvent = (event?: Event) => {
if (!(event instanceof KeyboardEvent) || !event.target) return
if (isExpandCellKey(event)) {
if (isVisible.value && !isActiveInputElementExist(event)) {
handleClose()
} else {
onExpand()
}
return true
}
}
onMounted(() => {
cellEventHook?.on(onCellEvent)
if (isUnderLookup.value || !isCanvasInjected || !clientMousePosition || isExpandedFormOpen.value) return
const position = { clientX: clientMousePosition.clientX, clientY: clientMousePosition.clientY + 2 }
forcedNextTick(() => {
if (onCellEvent(canvasCellEventData.event)) return
if (getElementAtMouse('.nc-canvas-table-editable-cell-wrapper .nc-textarea-expand', position)) {
onExpand()
} else if (getElementAtMouse('.nc-canvas-table-editable-cell-wrapper .nc-textarea-generate', position)) {
generate()
} else if (isRichMode.value || props.isAi) {
onExpand()
}
})
})
onUnmounted(() => {
cellEventHook?.off(onCellEvent)
})
/**
* Tracks whether the size has been updated.
* Prevents redundant updates when resizing elements.
*/
const isSizeUpdated = ref(false)
/**
* Controls whether the next size update should be skipped.
* Used to avoid unnecessary updates on initialization.
*/
const skipSizeUpdate = ref(true)
watch(isVisible, (open) => {
if (open) return
isSizeUpdated.value = false
skipSizeUpdate.value = true
})
/**
* Updates the size of the text area based on stored dimensions in localStorage.
* Retrieves the stored size and applies it to the corresponding text area element.
*/
const updateSize = () => {
try {
const size = localStorage.getItem(STORAGE_KEY)
let elem = document.querySelector('.nc-text-area-expanded') as HTMLElement
if (isRichMode.value) {
elem = document.querySelector('.nc-long-text-expanded-modal .nc-textarea-rich-editor .tiptap') as HTMLElement
elem = document.querySelector('.nc-long-text-expanded-modal .nc-textarea-rich-editor .tiptap.ProseMirror') as HTMLElement
}
const parsedJSON = JSON.parse(size)
@@ -322,62 +378,62 @@ const updateSize = () => {
}
}
watch(
[isVisible, inputRef],
(value) => {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
/**
* Retrieves the element that should be observed for resizing.
* @returns {HTMLElement | null} The resize target element.
*/
const getResizeEl = () => {
if (!inputWrapperRef.value) return null
if (!isVisible.value) {
return
}
localStorage.setItem(STORAGE_KEY, JSON.stringify({ width, height }))
}
})
if (value) {
if (isRichMode.value && isVisible.value) {
setTimeout(() => {
const el = document.querySelector('.nc-long-text-expanded-modal .nc-textarea-rich-editor .tiptap') as HTMLElement
if (!el) return
observer.observe(el)
updateSize()
}, 50)
} else {
updateSize()
}
} else {
observer.disconnect()
}
},
{
immediate: true,
},
)
watch(textAreaRef, (el) => {
if (el && !isExpandedFormOpen.value && !isEditColumn.value && !isForm.value) {
el.focus()
if (isRichMode.value) {
return inputWrapperRef.value.querySelector(
'.nc-long-text-expanded-modal .nc-textarea-rich-editor .tiptap.ProseMirror',
) as HTMLElement
}
})
onMounted(() => {
if (isUnderLookup.value || !isCanvasInjected || !clientMousePosition || isExpandedFormOpen.value) return
const position = { clientX: clientMousePosition.clientX, clientY: clientMousePosition.clientY + 2 }
forcedNextTick(() => {
if (getElementAtMouse('.nc-canvas-table-editable-cell-wrapper .nc-textarea-expand', position)) {
onExpand()
} else if (getElementAtMouse('.nc-canvas-table-editable-cell-wrapper .nc-textarea-generate', position)) {
generate()
} else if (isRichMode.value || props.isAi) {
onExpand()
}
})
return inputWrapperRef.value.querySelector('.nc-text-area-expanded') as HTMLElement
}
useResizeObserver(inputWrapperRef, () => {
/**
* Updates the size of the resize element when the modal becomes visible.
*/
if (!isSizeUpdated.value) {
nextTick(() => {
until(() => !!getResizeEl())
.toBeTruthy()
.then(() => {
updateSize()
})
})
isSizeUpdated.value = true
return
}
/**
* When the size is manually updated, this callback is triggered again.
* To prevent unnecessary updates at that time, we skip the update.
*/
if (skipSizeUpdate.value) {
skipSizeUpdate.value = false
return
}
const resizeEl = getResizeEl()
if (!resizeEl) return
const { width, height } = resizeEl.getBoundingClientRect()
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
width,
height,
}),
)
})
</script>
@@ -615,7 +671,7 @@ onMounted(() => {
</NcButton>
</NcTooltip>
<NcTooltip v-if="!isVisible && !isForm" placement="bottom" class="nc-action-icon">
<template #title>{{ $t('title.expand') }}</template>
<template #title>{{ isExpandedFormOpen ? $t('title.expand') : $t('tooltip.expandShiftSpace') }}</template>
<NcButton
type="secondary"
size="xsmall"

View File

@@ -34,7 +34,7 @@ const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
@@ -332,7 +332,7 @@ function handleSelectTime(value?: dayjs.Dayjs) {
}
const cellValue = computed(() => localState.value?.format(parseProp(column.value.meta).is12hrFormat ? 'hh:mm A' : 'HH:mm') ?? '')
const canvasCellEventData = inject(CanvasCellEventDataInj)!
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
onMounted(() => {
if (isGrid.value && isCanvasInjected && !isExpandedForm.value && !isEditColumn.value && !isUnderLookup.value) {
open.value = true

View File

@@ -55,7 +55,7 @@ const localState = computed({
return undefined
}
let convertingValue = modelValue
const valueNumber: number = Number(modelValue)
const valueNumber = Number(modelValue)
if (!isNaN(valueNumber)) {
// FIXME: currently returned value is in minutes
// so need to * 60 and need to be removed if changed to seconds

View File

@@ -22,17 +22,28 @@ const trim = (val: string) => val?.trim?.()
// Used in the logic of when to display error since we are not storing the url if it's not valid
const localState = ref(props.modelValue)
const inputRef = ref<HTMLInputElement>()
const isFocused = ref(false)
const vModel = computed({
get: () => props.modelValue,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(trim(val))) || !val || isForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(trim(val))) || !val || isForm.value || isEditColumn.value) {
emit('update:modelValue', val)
}
},
})
const url = computed(() => {
if (!vModel.value) return ''
const updatedValue = addMissingUrlSchma(vModel.value ?? '')
if (!isValidURL(updatedValue)) return ''
return updatedValue
})
const focus: VNodeRef = (el) => {
if (!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value) {
inputRef.value = el as HTMLInputElement
@@ -48,7 +59,10 @@ onBeforeUnmount(() => {
localState.value &&
!isValidURL(trim(localState.value))
) {
message.error(t('msg.error.invalidURL'))
if (!isEditColumn.value) {
message.error(t('msg.error.invalidURL'))
}
localState.value = undefined
return
}
@@ -60,17 +74,30 @@ onMounted(() => {
inputRef.value?.focus()
}
})
const onBlur = () => {
editEnabled.value = false
isFocused.value = false
}
const showClicableLink = computed(() => {
return (isExpandedFormOpen.value || isForm.value) && !isFocused.value && url.value
})
</script>
<template>
<div class="flex flex-row items-center justify-between w-full h-full">
<div class="flex flex-row items-center justify-between w-full h-full relative">
<!-- eslint-disable vue/use-v-on-exact -->
<input
:ref="focus"
v-model="vModel"
class="nc-cell-field outline-none w-full py-1 bg-transparent h-full"
:class="{
'!text-transparent': showClicableLink,
}"
:disabled="readOnly"
@blur="editEnabled = false"
@blur="onBlur"
@focus="isFocused = true"
@keydown.down.stop
@keydown.left.stop
@keydown.right.stop
@@ -80,5 +107,17 @@ onMounted(() => {
@selectstart.capture.stop
@mousedown.stop
/>
<div
v-if="showClicableLink"
class="nc-cell-field absolute inset-0 flex items-center max-w-full overflow-hidden pointer-events-none"
>
<a
class="truncate text-primary cursor-pointer pointer-events-auto no-user-select"
:href="url"
@click.prevent="confirmPageLeavingRedirect(url)"
>
{{ vModel }}
</a>
</div>
</div>
</template>

View File

@@ -20,12 +20,13 @@ const trim = (val: string) => val?.trim?.()
const isValid = computed(() => value && isValidURL(trim(value)))
const url = computed(() => {
if (!value || !isValidURL(trim(value))) return ''
if (!value) return ''
/** add url scheme if missing */
if (/^https?:\/\//.test(trim(value))) return trim(value)
const updatedValue = addMissingUrlSchma(value ?? '')
return `https://${trim(value)}`
if (!isValidURL(updatedValue)) return ''
return updatedValue
})
const { cellUrlOptions } = useCellUrlConfig(url)

View File

@@ -38,7 +38,7 @@ const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(trim(val))) || !val || isForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(trim(val))) || !val || isForm.value || isEditColumn.value) {
emit('update:modelValue', val)
}
},

View File

@@ -62,7 +62,7 @@ const isFocusing = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const searchVal = ref<string | null>()

View File

@@ -11,7 +11,7 @@ const { modelValue, isPk = false } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const { showNull } = useGlobal()
@@ -282,7 +282,7 @@ function handleSelectDate(value?: dayjs.Dayjs) {
}
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const canvasCellEventData = inject(CanvasCellEventDataInj)!
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
onMounted(() => {
if (isGrid.value && isCanvasInjected && !isExpandedForm.value && !isEditColumn.value && !isUnderLookup.value) {
open.value = true

View File

@@ -21,7 +21,7 @@ const {
const dropZoneRef = ref<HTMLDivElement>()
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const sortableRef = ref<HTMLDivElement>()
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, readOnly)

View File

@@ -35,9 +35,12 @@ const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const canvasCellEventData = inject(CanvasCellEventDataInj, reactive<CanvasCellEventDataInjType>({}))
const isCanvasInjected = inject(IsCanvasInjectionInj, false)
const clientMousePosition = inject(ClientMousePositionInj)
const canvasSelectCell = inject(CanvasSelectCellInj)
const canvasSelectCell = inject(CanvasSelectCellInj, null)
const cellEventHook = inject(CellEventHookInj, null)
const { isMobileMode } = useGlobal()
@@ -254,9 +257,28 @@ defineExpose({
updateAttachmentTitle,
})
const onCellEvent = (event?: Event) => {
if (!(event instanceof KeyboardEvent) || !event.target || isActiveInputElementExist(event) || !visibleItems.value.length) return
if (isExpandCellKey(event)) {
if (modalVisible.value) {
modalRendered.value = false
modalVisible.value = false
} else {
onExpand()
}
return true
}
}
onMounted(() => {
cellEventHook?.on(onCellEvent)
if (!isUnderLookup.value && isCanvasInjected && !isExpandedForm.value && isGrid.value) {
forcedNextTick(() => {
if (onCellEvent(canvasCellEventData.event)) return
const clickableSelectors = ['.view-attachments', '.add-files', '.nc-attachment', '.empty-add-files']
.map((selector) => `.nc-canvas-table-editable-cell-wrapper ${selector}`)
.join(', ')
@@ -274,6 +296,10 @@ onMounted(() => {
})
}
})
onUnmounted(() => {
cellEventHook?.off(onCellEvent)
})
</script>
<template>
@@ -443,7 +469,9 @@ onMounted(() => {
}"
:style="isGrid && (!rowHeight || rowHeight === 1) ? { top: '50%', transform: 'translateY(-50%)' } : undefined"
>
<template #title>{{ $t('activity.viewAttachment') }}</template>
<template #title>
{{ isExpandedForm ? $t('activity.viewAttachment') : `${$t('activity.viewAttachment')} '${$t('tooltip.shiftSpace')}'` }}
</template>
<NcButton
type="secondary"
size="xsmall"

View File

@@ -13,6 +13,27 @@ const value = useVModel(props, 'value')
const selectedFeatures = ref<Record<string, boolean>>({})
const isFeatureVisible = (feature: BetaFeatureType) => {
return (!feature?.isEE || isEeUI) && (!feature?.isEngineering || isEngineeringModeOn.value)
}
const isAllFeaturesEnabled = computed({
get: () => {
return features.value.every((feature) => {
return !isFeatureVisible(feature) || selectedFeatures.value[feature.id]
})
},
set: (value: boolean) => {
features.value.forEach((feature) => {
if (isFeatureVisible(feature) && feature.enabled !== value) {
if (toggleFeature(feature.id, value)) {
selectedFeatures.value[feature.id] = value
}
}
})
},
})
const saveExperimentalFeatures = () => {
features.value.forEach((feature) => {
if (selectedFeatures.value[feature.id] !== feature.enabled) {
@@ -83,15 +104,27 @@ onUnmounted(() => {
</nc-button>
</div>
<div class="text-sm font-weight-500 text-gray-600 leading-5 m-4 mb-0">
{{ $t('labels.toggleExperimentalFeature') }}
<div class="text-sm font-weight-500 text-gray-600 leading-5 m-4 mb-0 flex items-center justify-between gap-3 pr-3">
<span>
{{ $t('labels.toggleExperimentalFeature') }}
</span>
<NcTooltip
:title="
isAllFeaturesEnabled
? `${$t('general.disable')} ${$t('general.all')}`
: `${$t('general.enable')} ${$t('general.all')}`
"
class="flex"
>
<NcSwitch v-model:checked="isAllFeaturesEnabled" />
</NcTooltip>
</div>
<div class="h-full overflow-y-auto nc-scrollbar-thin flex-grow m-4 !rounded-lg">
<div ref="contentRef" class="border-1 !border-gray-200 !rounded-lg">
<template v-for="feature in features" :key="feature.id">
<div
v-if="(!feature.isEE || isEeUI) && (!feature?.isEngineering || isEngineeringModeOn)"
v-if="isFeatureVisible(feature)"
class="border-b-1 px-3 flex gap-2 flex-col py-2 !border-gray-200 last:border-b-0"
>
<div class="flex items-center justify-between">

View File

@@ -103,7 +103,7 @@ const [searchActive] = useToggle()
const filterQuery = ref('')
const keys = ref<Record<string, number>>({})
const isTableDeleteDialogVisible = ref(false)
const isProjectDeleteDialogVisible = ref(false)
const isBaseDeleteDialogVisible = ref(false)
const { refreshViewTabTitle } = useViewsStore()
@@ -388,7 +388,7 @@ function openErdView(source: SourceType) {
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgProjectErd'), {
const { close } = useDialog(resolveComponent('DlgBaseErd'), {
'modelValue': isOpen,
'sourceId': source!.id,
'onUpdate:modelValue': () => closeDialog(),
@@ -453,7 +453,7 @@ const tableDelete = () => {
}
const projectDelete = () => {
isProjectDeleteDialogVisible.value = true
isBaseDeleteDialogVisible.value = true
$e('c:project:delete')
}
@@ -1057,8 +1057,8 @@ const shouldOpenContextMenu = computed(() => {
:table-id="contextMenuTarget.value?.id"
:base-id="base?.id"
/>
<DlgProjectDelete v-model:visible="isProjectDeleteDialogVisible" :base-id="base?.id" />
<DlgProjectDuplicate v-if="selectedProjectToDuplicate" v-model="isDuplicateDlgOpen" :base="selectedProjectToDuplicate" />
<DlgBaseDelete v-model:visible="isBaseDeleteDialogVisible" :base-id="base?.id" />
<DlgBaseDuplicate v-if="selectedProjectToDuplicate" v-model="isDuplicateDlgOpen" :base="selectedProjectToDuplicate" />
<GeneralModal v-model:visible="isErdModalOpen" size="large">
<div class="h-[80vh]">
<LazyDashboardSettingsErd :base-id="base?.id" :source-id="activeBaseId" />

View File

@@ -6,6 +6,7 @@ const { modelValue, baseId, sourceId, transition } = defineProps<{
baseId: string
sourceId: string
transition?: string
showBackBtn?: boolean
}>()
const emit = defineEmits(['update:modelValue', 'back'])
@@ -450,7 +451,9 @@ const collapseKey = ref('')
}
"
>
{{ $t('general.back') }}
<GeneralIcon v-if="showBackBtn" icon="chevronLeft" class="mr-1" />
{{ showBackBtn ? $t('general.back') : $t('general.cancel') }}
</nc-button>
<nc-button

View File

@@ -0,0 +1,383 @@
<script setup lang="ts">
import tinycolor from 'tinycolor2'
import { type BaseType, WorkspaceUserRoles } from 'nocodb-sdk'
const props = defineProps<{
modelValue: boolean
base: BaseType
}>()
const emit = defineEmits(['update:modelValue'])
const dialogShow = useVModel(props, 'modelValue', emit)
const { navigateToProject } = useGlobal()
const { refreshCommandPalette } = useCommandPalette()
const { api } = useApi()
const { $e, $poller } = useNuxtApp()
const basesStore = useBases()
const { workspacesList, activeWorkspace } = useWorkspace()
const { loadProjects, createProject: _createProject } = basesStore
const options = ref({
includeData: true,
includeViews: true,
includeHooks: true,
includeComments: true,
})
const targetWorkspace = ref(activeWorkspace)
const errorMessage = ref()
// Used to handle different Action in different states in Modal
// pending -> Initial state
// loading -> Set when duplicate is triggered
const status = ref<'pending' | 'success' | 'error' | 'loading'>('pending')
const isEaster = ref(false)
const dropdownOpen = ref(false)
const optionsToExclude = computed(() => {
const { includeData, includeViews, includeHooks, includeComments } = options.value
return {
excludeData: !includeData,
excludeViews: !includeViews,
excludeHooks: !includeHooks,
excludeComments: !includeComments,
}
})
const workspaceOptions = computed(() => {
if (!isEeUI) return []
return workspacesList.filter((ws) =>
[WorkspaceUserRoles.CREATOR, WorkspaceUserRoles.OWNER].includes(ws.roles as WorkspaceUserRoles),
)
})
const isLoading = computed(() => status.value === 'loading')
const targetBase = ref()
const _duplicate = async () => {
try {
status.value = 'loading'
// pick a random color from array and assign to base
const color = baseThemeColors[Math.floor(Math.random() * 1000) % baseThemeColors.length]
const tcolor = tinycolor(color)
const complement = tcolor.complement()
const jobData = await api.base.duplicate(props.base.id as string, {
options: optionsToExclude.value,
base: {
fk_workspace_id: isEeUI ? (targetWorkspace.value?.id ? targetWorkspace.value.id : props.base.fk_workspace_id) : null,
type: props.base.type,
color,
meta: JSON.stringify({
theme: {
primaryColor: color,
accentColor: complement.toHex8String(),
},
iconColor: parseProp(props.base.meta).iconColor,
}),
},
})
$poller.subscribe(
{ id: jobData.id },
async (data: {
id: string
status?: string
data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
const resBases = await loadProjects('workspace', targetWorkspace?.value?.id)
targetBase.value = resBases.find((b) => b.id === jobData.base_id)
status.value = 'success'
refreshCommandPalette()
} else if (data.status === JobStatus.FAILED) {
status.value = 'error'
await loadProjects('workspace')
refreshCommandPalette()
}
}
},
)
$e('a:base:duplicate')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
errorMessage.value = await extractSdkResponseErrorMsg(e)
status.value = 'error'
dialogShow.value = false
}
}
const selectOption = (option: WorkspaceType) => {
targetWorkspace.value = option
dropdownOpen.value = false
}
const handleActionClick = () => {
switch (status.value) {
case 'pending': {
_duplicate()
break
}
case 'error': {
targetBase.value = null
errorMessage.value = null
status.value = 'pending'
break
}
case 'success': {
const base = targetBase.value
navigateToProject({
workspaceId: isEeUI ? base.fk_workspace_id : undefined,
baseId: base.id,
type: base.type,
})
dialogShow.value = false
break
}
}
}
watch(dialogShow, (newVal) => {
if (!newVal) {
status.value = 'pending'
}
})
onKeyStroke('Enter', () => {
// should only trigger this when our modal is open
if (dialogShow.value) {
_duplicate()
}
})
</script>
<template>
<GeneralModal
v-if="base"
v-model:visible="dialogShow"
:mask-style="{
'background-color': 'rgba(0, 0, 0, 0.08)',
}"
:mask-closable="!isLoading"
:keyboard="!isLoading"
class="!w-[30rem]"
wrap-class-name="nc-modal-base-duplicate"
>
<div>
<div class="text-base text-nc-content-gray-emphasis leading-6 font-bold self-center" @dblclick="isEaster = !isEaster">
<template v-if="['pending', 'loading'].includes(status)">
{{ $t('labels.duplicateBaseBaseTitle', { baseTitle: base.title }) }}
</template>
<template v-else-if="status === 'success'">
<div class="flex items-center gap-2">
<GeneralIcon class="text-white w-6 h-6" icon="checkFill" />
<div class="text-nc-content-gray-emphasis font-semibold">
{{ $t('labels.duplicateBaseSuccessfull') }}
</div>
</div>
</template>
<template v-else-if="status === 'error'">
<div class="flex items-center gap-2">
<GeneralIcon icon="ncInfoSolid" class="flex-none !text-nc-content-red-dark w-6 h-6" />
<div class="text-nc-content-gray-emphasis font-semibold">
{{ $t('labels.duplicateBaseFailed') }}
</div>
</div>
</template>
</div>
<template v-if="['pending', 'loading'].includes(status)">
<div class="mt-5 flex gap-3 flex-col">
<div
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeData = !options.includeData"
>
<NcSwitch :checked="options.includeData" />
{{ $t('labels.includeRecords') }}
</div>
<template v-if="isEaster">
<div
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeViews = !options.includeViews"
>
<NcSwitch :checked="options.includeViews" />
{{ $t('labels.includeView') }}
</div>
<div
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeHooks = !options.includeHooks"
>
<NcSwitch :checked="options.includeHooks" />
{{ $t('labels.includeWebhook') }}
</div>
</template>
<div
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeComments = !options.includeComments"
>
<NcSwitch :checked="options.includeComments" />
{{ $t('labels.includeComments') }}
</div>
</div>
<div
:class="{
'mb-5': !isEeUI,
}"
class="mt-5 text-nc-content-gray-subtle2 font-medium"
>
{{ $t('labels.baseDuplicateMessage') }}
</div>
<div v-if="isEeUI" class="mb-5">
<NcDivider divider-class="!my-5" />
<div class="text-nc-content-gray font-medium leading-5">
{{ $t('labels.workspace') }}
<NcDropdown v-model:visible="dropdownOpen" class="mt-2">
<div
class="rounded-lg border-1 transition-all cursor-pointer flex items-center border-nc-border-grey-medium h-8 py-1 gap-2 px-3"
style="box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.08)"
:class="{
'!border-brand-500 !shadow-selected': dropdownOpen,
}"
>
<GeneralWorkspaceIcon size="small" :workspace="targetWorkspace" />
<div class="flex-1 capitalize truncate">
{{ targetWorkspace?.title }}
</div>
<div class="flex gap-2 items-center">
<div v-if="activeWorkspace?.id === targetWorkspace?.id" class="text-nc-content-gray-muted leading-4.5 text-xs">
{{ $t('labels.currentWorkspace') }}
</div>
<GeneralIcon
:class="{
'transform rotate-180': dropdownOpen,
}"
class="text-nc-content-gray transition-all w-4 h-4"
icon="ncChevronDown"
/>
</div>
</div>
<template #overlay>
<NcList
:value="targetWorkspace"
:item-height="28"
close-on-select
class="nc-base-workspace-selection"
:min-items-for-search="6"
container-class-name="w-full"
:list="workspaceOptions"
option-label-key="title"
>
<template #listHeader>
<div class="text-nc-content-gray-muted text-[13px] px-3 pt-2.5 pb-1.5 font-medium leading-5">
{{ $t('labels.duplicateBaseMessage') }}
</div>
<NcDivider />
</template>
<template #listItem="{ option }">
<div class="flex gap-2 w-full items-center" @click="selectOption(option)">
<GeneralWorkspaceIcon :workspace="option" size="small" />
<div class="flex-1 text-[13px] truncate font-semibold leading-5 capitalize w-full">
{{ option.title }}
</div>
<div class="flex items-center gap-2">
<div v-if="activeWorkspace?.id === option.id" class="text-nc-content-gray-muted leading-4.5 text-xs">
{{ $t('labels.currentWorkspace') }}
</div>
<GeneralIcon v-if="option.id === targetWorkspace?.id" class="text-brand-500 w-4 h-4" icon="ncCheck" />
</div>
</div>
</template>
</NcList>
</template>
</NcDropdown>
</div>
</div>
</template>
<template v-else-if="status === 'success'">
<div class="text-nc-content-gray-emphasis my-5 font-medium">
Base <span class="font-bold leading-5">"{{ base.title }}"</span> has finished duplication.
</div>
</template>
<template v-else-if="status === 'error'">
<div class="text-nc-content-gray-emphasis my-5 font-medium">{{ $t('labels.errorMessage') }} {{ errorMessage }}</div>
</template>
</div>
<div class="flex flex-row gap-x-2 justify-end">
<NcButton v-if="!isLoading" key="back" type="secondary" size="small" @click="dialogShow = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton
key="submit"
v-e="['a:base:duplicate']"
size="small"
:loading="isLoading"
:disabled="isLoading"
@click="handleActionClick"
>
<template v-if="status === 'pending'"> {{ $t('general.duplicate') }} {{ $t('objects.project') }} </template>
<template v-else-if="status === 'loading'"> Duplicating {{ $t('objects.project') }} </template>
<template v-else-if="status === 'success'"> {{ $t('labels.goToBase') }} </template>
<template v-else-if="status === 'error'"> {{ $t('labels.tryAgain') }} </template>
</NcButton>
</div>
</GeneralModal>
</template>
<style scoped lang="scss">
:deep(.ant-modal-mask) {
@apply !bg-black !bg-opacity-[8%];
}
.nc-list-root {
@apply !w-[432px] !pt-0;
}
</style>
<style lang="scss">
.nc-base-workspace-selection {
.nc-list {
@apply !px-1;
.nc-list-item {
@apply !py-1;
}
}
}
</style>

View File

@@ -105,31 +105,37 @@ defineExpose({
<GeneralModal
v-model:visible="dialogShow"
:class="{ active: dialogShow }"
:closable="!isLoading"
:mask-closable="!isLoading"
:keyboard="!isLoading"
centered
:mask-style="{
'background-color': 'rgba(0, 0, 0, 0.08)',
}"
wrap-class-name="nc-modal-column-duplicate"
:footer="null"
class="!w-[30rem]"
@keydown.esc="dialogShow = false"
>
<div>
<div class="prose-xl font-bold self-center">{{ $t('general.duplicate') }} {{ $t('objects.column') }}</div>
<div class="text-base text-nc-content-gray-emphasis leading-6 font-bold self-center">
{{ $t('general.duplicate') }} {{ $t('objects.column') }} "{{ column.title }}"
</div>
<div class="mt-4">Are you sure you want to duplicate the field?</div>
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div>
<a-divider class="!m-0 !p-0 !my-2" />
<div class="text-xs p-2">
<a-checkbox v-model:checked="options.includeData" :disabled="isLoading">{{ $t('labels.includeData') }}</a-checkbox>
<div class="mt-5 flex gap-3 flex-col">
<div
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeData = !options.includeData"
>
<NcSwitch :checked="options.includeData" />
{{ $t('labels.includeData') }}
</div>
</div>
</div>
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end">
<NcButton v-if="!isLoading" key="back" type="secondary" @click="dialogShow = false">{{ $t('general.cancel') }}</NcButton>
<NcButton key="submit" type="primary" :loading="isLoading" @click="_duplicate">{{ $t('general.confirm') }} </NcButton>
<div class="flex flex-row gap-x-2 mt-5 justify-end">
<NcButton v-if="!isLoading" key="back" type="secondary" size="small" @click="dialogShow = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton key="submit" type="primary" size="small" :loading="isLoading" @click="_duplicate"> Duplicate Field </NcButton>
</div>
</GeneralModal>
</template>

View File

@@ -325,9 +325,9 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
ref="divRef"
:class="{
'border-primary/100': isDivFocused,
'p-1': emailBadges?.length > 1,
'p-1': emailBadges?.length > 0,
}"
class="flex items-center border-1 gap-1 w-full overflow-x-scroll nc-scrollbar-x-md items-center h-10 rounded-lg !min-w-96"
class="flex items-center flex-wrap border-1 gap-1 w-full overflow-x-scroll nc-scrollbar-x-md min-h-10 rounded-lg !min-w-96"
tabindex="0"
@blur="isDivFocused = false"
@click="focusOnDiv"
@@ -335,12 +335,12 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
<span
v-for="(email, index) in emailBadges"
:key="email"
class="border-1 text-gray-800 first:ml-1 bg-gray-100 rounded-md flex items-center px-2 py-1"
class="border-1 text-nc-content-gray bg-nc-bg-gray-light rounded-md flex items-center px-1 whitespace-nowrap"
>
{{ email }}
<component
:is="iconMap.close"
class="ml-0.5 hover:cursor-pointer mt-0.5 w-4 h-4"
class="ml-0.5 hover:(cursor-pointer text-nc-content-gray-subtle) mt-0.5 w-4 h-4 text-nc-content-gray-subtle2"
@click="emailBadges.splice(index, 1)"
/>
</span>
@@ -350,7 +350,7 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
v-model="inviteData.email"
:disabled="isLoading"
:placeholder="$t('activity.enterEmail')"
class="w-full min-w-36 outline-none px-2"
class="flex-1 min-w-36 outline-none px-2"
data-testid="email-input"
@blur="isDivFocused = false"
@keyup.enter="handleEnter"

View File

@@ -5,6 +5,7 @@ const { modelValue, baseId, transition } = defineProps<{
modelValue: boolean
baseId: string
transition?: string
showBackBtn?: boolean
}>()
const emit = defineEmits(['update:modelValue', 'back'])
@@ -347,7 +348,9 @@ onUnmounted(() => {
}
"
>
{{ $t('general.back') }}
<GeneralIcon v-if="showBackBtn" icon="chevronLeft" class="mr-1" />
{{ showBackBtn ? $t('general.back') : $t('general.cancel') }}
</NcButton>
<NcButton v-else key="abort" type="danger" size="small" @click="abortListening">
{{ $t('general.abort') }}

View File

@@ -1,164 +0,0 @@
<script setup lang="ts">
import tinycolor from 'tinycolor2'
import type { BaseType } from 'nocodb-sdk'
const props = defineProps<{
modelValue: boolean
base: BaseType
}>()
const emit = defineEmits(['update:modelValue'])
const { refreshCommandPalette } = useCommandPalette()
const { api } = useApi()
const { $e, $poller } = useNuxtApp()
const basesStore = useBases()
const { loadProjects, createProject: _createProject } = basesStore
const { bases } = storeToRefs(basesStore)
const { navigateToProject } = useGlobal()
const dialogShow = useVModel(props, 'modelValue', emit)
const options = ref({
includeData: true,
includeViews: true,
includeHooks: true,
})
const optionsToExclude = computed(() => {
const { includeData, includeViews, includeHooks } = options.value
return {
excludeData: !includeData,
excludeViews: !includeViews,
excludeHooks: !includeHooks,
}
})
const isLoading = ref(false)
const _duplicate = async () => {
try {
isLoading.value = true
// pick a random color from array and assign to base
const color = baseThemeColors[Math.floor(Math.random() * 1000) % baseThemeColors.length]
const tcolor = tinycolor(color)
const complement = tcolor.complement()
const jobData = await api.base.duplicate(props.base.id as string, {
options: optionsToExclude.value,
base: {
fk_workspace_id: props.base.fk_workspace_id,
type: props.base.type,
color,
meta: JSON.stringify({
theme: {
primaryColor: color,
accentColor: complement.toHex8String(),
},
iconColor: parseProp(props.base.meta).iconColor,
}),
},
})
$poller.subscribe(
{ id: jobData.id },
async (data: {
id: string
status?: string
data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
if (data.status === JobStatus.COMPLETED) {
await loadProjects('workspace')
const base = bases.value.get(jobData.base_id)
// open project after duplication
if (base) {
await navigateToProject({
workspaceId: isEeUI ? base.fk_workspace_id : undefined,
baseId: base.id,
type: base.type,
})
}
refreshCommandPalette()
isLoading.value = false
dialogShow.value = false
} else if (data.status === JobStatus.FAILED) {
message.error('Failed to duplicate project')
await loadProjects('workspace')
refreshCommandPalette()
isLoading.value = false
dialogShow.value = false
}
}
},
)
$e('a:base:duplicate')
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
isLoading.value = false
dialogShow.value = false
}
}
onKeyStroke('Enter', () => {
// should only trigger this when our modal is open
if (dialogShow.value) {
_duplicate()
}
})
const isEaster = ref(false)
</script>
<template>
<GeneralModal
v-if="base"
v-model:visible="dialogShow"
:mask-closable="!isLoading"
:keyboard="!isLoading"
class="!w-[30rem]"
wrap-class-name="nc-modal-base-duplicate"
>
<div>
<div class="font-medium text-lg text-gray-800 self-center" @dblclick="isEaster = !isEaster">
{{ $t('general.duplicate') }} {{ $t('objects.project') }}
</div>
<div class="mt-5">{{ $t('msg.warning.duplicateProject') }}</div>
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div>
<a-divider class="!m-0 !p-0 !my-2" />
<div class="text-xs p-2">
<a-checkbox v-model:checked="options.includeData" :disabled="isLoading">{{ $t('labels.includeData') }}</a-checkbox>
<a-checkbox v-model:checked="options.includeViews" :disabled="isLoading">{{ $t('labels.includeView') }}</a-checkbox>
<a-checkbox v-show="isEaster" v-model:checked="options.includeHooks" :disabled="isLoading">
{{ $t('labels.includeWebhook') }}
</a-checkbox>
</div>
</div>
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end">
<NcButton v-if="!isLoading" key="back" type="secondary" size="small" @click="dialogShow = false">{{
$t('general.cancel')
}}</NcButton>
<NcButton key="submit" v-e="['a:base:duplicate']" size="small" :loading="isLoading" @click="_duplicate"
>{{ $t('general.confirm') }}
</NcButton>
</div>
</GeneralModal>
</template>

View File

@@ -2,8 +2,9 @@
import { toRaw, unref } from '@vue/runtime-core'
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import { Upload } from 'ant-design-vue'
import { type TableType, charsetOptions } from 'nocodb-sdk'
import { type TableType, charsetOptions, charsetOptionsMap, ncHasProperties } from 'nocodb-sdk'
import rfdc from 'rfdc'
import type { ProgressMessageObjType } from '../../helpers/parsers/TemplateGenerator'
interface Props {
modelValue: boolean
@@ -12,12 +13,19 @@ interface Props {
sourceId: string
importDataOnly?: boolean
transition?: string
showBackBtn?: boolean
}
const { importType, importDataOnly = false, baseId, sourceId, transition, ...rest } = defineProps<Props>()
const { importType, importDataOnly = false, baseId, sourceId, transition, showBackBtn, ...rest } = defineProps<Props>()
const emit = defineEmits(['update:modelValue', 'back'])
enum ImportTypeTabs {
'upload' = 'upload',
'uploadFromUrl' = 'uploadFromUrl',
'uploadJSON' = 'uploadJSON',
}
const { $api, $importWorker } = useNuxtApp()
let importWorker: Worker
@@ -35,6 +43,7 @@ const isWorkerSupport = typeof Worker !== 'undefined'
const { t } = useI18n()
const progressMsg = ref('Parsing Data ...')
const progressMsgNew = ref<Record<string, string>>({})
const { tables } = storeToRefs(useBase())
@@ -64,6 +73,12 @@ const temporaryJson = ref({})
const jsonErrorText = ref('')
const activeTab = ref<ImportTypeTabs>(ImportTypeTabs.upload)
const isError = ref(false)
const refMonacoEditor = ref()
const useForm = Form.useForm
const defaultImportState = {
@@ -140,10 +155,6 @@ watch(
{ immediate: true },
)
const filterOption = (input = '', params: { key: string }) => {
return params.key?.toLowerCase().includes(input.toLowerCase())
}
const isPreImportFileFilled = computed(() => {
return importState.fileList?.length > 0
})
@@ -153,21 +164,52 @@ const isPreImportUrlFilled = computed(() => {
})
const isPreImportJsonFilled = computed(() => {
return JSON.stringify(importState.jsonEditor).length > 2 && !jsonErrorText.value
})
const disablePreImportButton = computed(() => {
if (isImportTypeCsv.value) {
return isPreImportFileFilled.value === isPreImportUrlFilled.value
} else if (IsImportTypeExcel.value) {
return isPreImportFileFilled.value === isPreImportUrlFilled.value
} else if (isImportTypeJson.value) {
return !isPreImportFileFilled.value && !isPreImportJsonFilled.value
try {
return refMonacoEditor.value.isValid && JSON.stringify(importState.jsonEditor).length > 2
} catch {
return false
}
})
const isError = ref(false)
const refMonacoEditor = ref()
const localImportError = ref('')
const importError = computed(() => localImportError.value ?? templateEditorRef.value?.importError ?? '')
const maxFileUploadLimit = computed(() => (isImportTypeCsv.value ? 3 : 1))
const hideUpload = computed(() => preImportLoading.value || importState.fileList.length >= maxFileUploadLimit.value)
const disablePreImportButton = computed(() => {
if (activeTab.value === ImportTypeTabs.upload) {
return !isPreImportFileFilled.value
} else if (activeTab.value === ImportTypeTabs.uploadFromUrl) {
return !isPreImportUrlFilled.value
} else if (activeTab.value === ImportTypeTabs.uploadJSON) {
return !isPreImportJsonFilled.value
}
return true
})
const importBtnText = computed(() => {
// configure field screen
if (templateEditorModal.value) {
if (importLoading.value) {
return importDataOnly ? t('labels.uploading') : t('labels.importing')
}
return importDataOnly ? t('activity.upload') : t('activity.import')
}
const type = isImportTypeJson.value ? t('labels.jsonCapitalized') : t('objects.files')
// upload file screen
if (preImportLoading.value) {
return importDataOnly ? `${t('labels.uploading')} ${type}` : `${t('labels.importing')} ${type}`
}
return importDataOnly ? `${t('activity.upload')} ${type}` : `${t('activity.import')} ${type}`
})
const disableImportButton = computed(() => !templateEditorRef.value?.isValid || isError.value)
@@ -176,34 +218,37 @@ let templateGenerator: CSVTemplateAdapter | JSONTemplateAdapter | ExcelTemplateA
async function handlePreImport() {
preImportLoading.value = true
isParsingData.value = true
localImportError.value = ''
if (!baseTables.value.get(baseId)) {
await loadProjectTables(baseId)
}
const isPreImportFileMode = isPreImportFileFilled.value && activeTab.value === ImportTypeTabs.upload
if (isImportTypeCsv.value) {
if (isPreImportFileFilled.value) {
if (isPreImportFileMode) {
await parseAndExtractData(importState.fileList as streamImportFileList)
} else if (isPreImportUrlFilled.value) {
try {
await validate()
await parseAndExtractData(importState.url)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
localImportError.value = await extractSdkResponseErrorMsg(e)
}
}
} else if (isImportTypeJson.value) {
if (isPreImportFileFilled.value) {
if (isPreImportFileMode) {
if (isWorkerSupport && importWorker) {
await parseAndExtractData(importState.fileList as streamImportFileList)
} else {
await parseAndExtractData((importState.fileList as importFileList)[0].data)
}
} else {
} else if (isPreImportJsonFilled.value) {
await parseAndExtractData(JSON.stringify(importState.jsonEditor))
}
} else if (IsImportTypeExcel) {
if (isPreImportFileFilled.value) {
if (isPreImportFileMode) {
if (isWorkerSupport && importWorker) {
await parseAndExtractData(importState.fileList as streamImportFileList)
} else {
@@ -214,7 +259,7 @@ async function handlePreImport() {
await validate()
await parseAndExtractData(importState.url)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
localImportError.value = await extractSdkResponseErrorMsg(e)
}
}
}
@@ -224,21 +269,27 @@ async function handlePreImport() {
}
async function handleImport() {
localImportError.value = ''
try {
if (!templateGenerator && !importWorker) {
message.error(t('msg.error.templateGeneratorNotFound'))
localImportError.value = t('msg.error.templateGeneratorNotFound')
return
}
importLoading.value = true
await templateEditorRef.value.importTemplate()
} catch (e: any) {
return message.error(await extractSdkResponseErrorMsg(e))
} finally {
importLoading.value = false
templateEditorModal.value = false
Object.assign(importState, defaultImportState)
dialogShow.value = false
} catch (e: any) {
console.log(e)
const errorMsg = await extractSdkResponseErrorMsg(e)
localImportError.value = errorMsg
return
} finally {
importLoading.value = false
}
dialogShow.value = false
}
function rejectDrop(fileList: UploadFile[]) {
@@ -249,6 +300,7 @@ function rejectDrop(fileList: UploadFile[]) {
function handleChange(info: UploadChangeParam) {
const status = info.file.status
if (status && status !== 'uploading' && status !== 'removed') {
if (isImportTypeCsv.value || (isWorkerSupport && importWorker)) {
if (!importState.fileList.find((f) => f.uid === info.file.uid)) {
@@ -284,9 +336,7 @@ function handleChange(info: UploadChangeParam) {
}
}
if (status === 'done') {
message.success(`Uploaded file ${info.file.name} successfully`)
} else if (status === 'error') {
if (status === 'error') {
message.error(`${t('msg.error.fileUploadFailed')} ${info.file.name}`)
}
}
@@ -312,26 +362,38 @@ function populateUniqueTableName(tn: string, draftTn: string[] = []) {
}
function getAdapter(val: any) {
const isPreImportFileMode = isPreImportFileFilled.value && activeTab.value === ImportTypeTabs.upload
if (isImportTypeCsv.value) {
if (isPreImportFileFilled.value) {
return new CSVTemplateAdapter(val, {
...importState.parserConfig,
importFromURL: false,
})
if (isPreImportFileMode) {
return new CSVTemplateAdapter(
val,
{
...importState.parserConfig,
importFromURL: false,
},
undefined,
unref(existingColumns),
)
} else {
return new CSVTemplateAdapter(val, {
...importState.parserConfig,
importFromURL: true,
})
return new CSVTemplateAdapter(
val,
{
...importState.parserConfig,
importFromURL: true,
},
undefined,
unref(existingColumns),
)
}
} else if (IsImportTypeExcel.value) {
if (isPreImportFileFilled.value) {
if (isPreImportFileMode) {
return new ExcelTemplateAdapter(val, importState.parserConfig, undefined, undefined, unref(existingColumns))
} else {
return new ExcelUrlTemplateAdapter(val, importState.parserConfig, $api, undefined, undefined, unref(existingColumns))
}
} else if (isImportTypeJson.value) {
if (isPreImportFileFilled.value) {
if (isPreImportFileMode) {
return new JSONTemplateAdapter(val, importState.parserConfig)
} else {
return new JSONTemplateAdapter(val, importState.parserConfig)
@@ -356,8 +418,14 @@ const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
customReqArgs.onSuccess()
}
const showMaxFileLimitError = ref(false)
/** check if the file size exceeds the limit */
const beforeUpload = (file: UploadFile) => {
const beforeUpload = (file: UploadFile, fileList: UploadFile[]) => {
if (importState.fileList.length + fileList.length > maxFileUploadLimit.value) {
showMaxFileLimitError.value = true
}
const exceedLimit = file.size! / 1024 / 1024 > 25
if (exceedLimit) {
message.error(`File ${file.name} is too big. The accepted file size is less than 25MB.`)
@@ -379,7 +447,10 @@ function extractImportWorkerPayload(value: UploadFile[] | ArrayBuffer | string)
importType = importType! ?? ImportType.CSV
let importSource: ImportSource
if (isPreImportFileFilled.value) {
const isPreImportFileMode = isPreImportFileFilled.value && activeTab.value === ImportTypeTabs.upload
if (isPreImportFileMode) {
importSource = ImportSource.FILE
} else if (isPreImportUrlFilled.value && importType !== ImportType.JSON) {
importSource = ImportSource.URL
@@ -452,7 +523,12 @@ async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
importWorker?.removeEventListener('message', handler, false)
break
case ImportWorkerResponse.PROGRESS:
progressMsg.value = payload
if (ncHasProperties<ProgressMessageObjType>(payload, ['title', 'value'])) {
progressMsgNew.value = { ...progressMsgNew.value, [payload.title]: payload?.value ?? '' }
} else {
progressMsg.value = payload
}
break
case ImportWorkerResponse.ERROR:
reject(payload)
@@ -473,7 +549,7 @@ async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
templateGenerator = getAdapter(val)
if (!templateGenerator) {
message.error(t('msg.error.templateGeneratorNotFound'))
localImportError.value = t('msg.error.templateGeneratorNotFound')
return
}
@@ -497,9 +573,19 @@ async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
}
templateEditorModal.value = true
showMaxFileLimitError.value = false
} catch (e: any) {
console.log(e)
message.error(await extractSdkResponseErrorMsg(e))
/**
* If it is import url and it fail to send req due to cross origin or any other reason the e type will be string
* @example: Failed to execute 'send' on 'XMLHttpRequest': Failed to load '<url>'
*/
if (typeof e === 'string' && isPreImportUrlFilled.value && activeTab.value === ImportTypeTabs.uploadFromUrl) {
localImportError.value = e.replace(importState.url, '').replace(/''/, '')
} else {
localImportError.value = (await extractSdkResponseErrorMsg(e)) || e?.toString()
}
}
}
@@ -516,10 +602,33 @@ onMounted(() => {
importState.parserConfig.autoSelectFieldTypes = importDataOnly
})
onUnmounted(() => {
const onCancelImport = () => {
$importWorker.terminate()
Object.assign(importState, defaultImportState)
preImportLoading.value = false
importLoading.value = false
templateData.value = undefined
importData.value = undefined
importColumns.value = []
templateEditorModal.value = false
isParsingData.value = false
temporaryJson.value = {}
jsonErrorText.value = ''
isError.value = false
localImportError.value = ''
}
onUnmounted(() => {
onCancelImport()
})
const onClickCancel = () => {
dialogShow.value = false
emit('back')
onCancelImport()
}
function handleJsonChange(newValue: any) {
try {
temporaryJson.value = newValue
@@ -530,6 +639,11 @@ function handleJsonChange(newValue: any) {
}
}
function handleResetImportError() {
localImportError.value = ''
templateEditorRef.value?.updateImportError?.('')
}
watch(
() => importState.fileList,
() => {
@@ -546,6 +660,11 @@ watch(
}
}, 500)
}
// Hide max file limit error on removing file
if (importState.fileList.length < maxFileUploadLimit.value && showMaxFileLimitError.value) {
showMaxFileLimitError.value = false
}
},
)
</script>
@@ -561,13 +680,18 @@ watch(
:transition-name="transition"
@keydown.esc="dialogShow = false"
>
<a-spin :spinning="isParsingData" :tip="progressMsg" size="large">
<div
class="relative"
:class="{
'cursor-wait': preImportLoading || importLoading,
}"
>
<div class="text-base font-weight-700 m-0 flex items-center gap-3">
<GeneralIcon :icon="importMeta.icon" class="w-6 h-6" />
{{ importMeta.header }}
<a
href="https://docs.nocodb.com/tables/create-table-via-import/"
class="!text-gray-500 prose-sm ml-auto"
class="!text-nc-content-gray-subtle2 text-sm font-weight-500 ml-auto"
target="_blank"
rel="noopener"
>
@@ -575,7 +699,12 @@ watch(
</a>
</div>
<div class="mt-5">
<div
class="mt-5"
:class="{
'pointer-events-none': importLoading,
}"
>
<LazyTemplateEditor
v-if="templateEditorModal"
ref="templateEditorRef"
@@ -595,205 +724,349 @@ watch(
@change="onChange"
/>
<div v-else>
<a-upload-dragger
v-model:fileList="importState.fileList"
name="file"
class="nc-modern-drag-import nc-input-import !scrollbar-thin-dull !py-4 !transition !rounded-lg !border-gray-200"
list-type="picture"
:accept="importMeta.acceptTypes"
:max-count="isImportTypeCsv ? 3 : 1"
:multiple="true"
:custom-request="customReqCbk"
:before-upload="beforeUpload"
:disabled="isImportTypeJson ? isPreImportJsonFilled && !isPreImportFileFilled : isPreImportUrlFilled"
@change="handleChange"
@reject="rejectDrop"
>
<component :is="iconMap.upload" class="w-6 h-6" />
<p class="!mt-2 text-[13px]">
{{ $t('msg.dropYourDocHere') }} {{ $t('general.or').toLowerCase() }}
<span class="text-nc-content-brand hover:underline">{{ $t('labels.browseFiles') }}</span>
</p>
<p class="!mt-3 text-[13px] text-gray-500">{{ $t('general.supported') }}: {{ importMeta.acceptTypes }}</p>
<p class="ant-upload-hint">
{{ importMeta.uploadHint }}
</p>
<template #itemRender="{ file, actions }">
<div class="flex items-center gap-4">
<div class="bg-gray-100 h-10 flex flex-shrink items-center justify-center rounded-lg">
<CellAttachmentIconView :item="{ title: file.name, mimetype: file.type }" class="w-9 h-9" />
<NcTabs v-model:activeKey="activeTab" class="nc-quick-import-tabs" @update:active-key="handleResetImportError">
<a-tab-pane :key="ImportTypeTabs.upload" :disabled="preImportLoading" class="!h-full">
<template #tab>
<div class="flex gap-2 items-center">
<span class="text-sm">{{ $t('general.upload') }} </span>
</div>
<div class="flex flex-col flex-grow min-w-[0px]">
<div class="text-[14px] text-[#15171A] font-weight-500">
<NcTooltip>
<template #title>
{{ file.name }}
</template>
<div class="relative mt-5">
<a-upload-dragger
v-model:fileList="importState.fileList"
name="file"
class="nc-modern-drag-import nc-input-import !scrollbar-thin-dull !py-4 !transition !rounded-lg !border-gray-200"
:class="{
hidden: hideUpload,
}"
list-type="picture"
:accept="importMeta.acceptTypes"
:max-count="maxFileUploadLimit"
:multiple="true"
:disabled="preImportLoading"
:custom-request="customReqCbk"
:before-upload="beforeUpload"
@change="handleChange"
@reject="rejectDrop"
>
<component :is="iconMap.upload" class="w-6 h-6" />
<p class="!mt-2 text-[13px]">
{{ $t('msg.dropYourDocHere') }} {{ $t('general.or').toLowerCase() }}
<span class="text-nc-content-brand hover:underline">{{ $t('labels.browseFiles') }}</span>
</p>
<p class="!mt-3 text-[13px] text-gray-500">{{ $t('general.supported') }}: {{ importMeta.acceptTypes }}</p>
<p class="ant-upload-hint">
{{ importMeta.uploadHint }}
</p>
<template #itemRender="{ file, actions }">
<div class="flex items-center gap-4">
<div class="bg-gray-100 h-10 w-10 flex flex-none items-center justify-center rounded-lg">
<GeneralIcon :icon="importMeta.icon" class="w-6 h-6 flex-none" />
</div>
<div class="flex flex-col flex-grow min-w-[0px] w-[calc(100%_-_233px)]">
<div class="flex-none">
<NcTooltip show-on-truncate-only class="truncate text-sm text-nc-content-gray font-weight-500">
<template #title>
{{ file.name }}
</template>
{{ file.name }}
</NcTooltip>
</div>
<div class="text-small text-nc-content-gray-muted font-weight-500">
{{ getReadableFileSize(file.size) }}
</div>
</div>
<template v-if="!preImportLoading">
<a-form-item class="flex-1 !my-0 max-w-[120px] min-w-[120px]">
<NcDropdown placement="bottomRight" overlay-class-name="overflow-hidden !w-[170px]">
<template #default="{ visible }">
<NcButton size="small" type="secondary" class="w-[120px] children:children:w-full !text-small">
<NcTooltip class="flex-none w-[85px] truncate text-left !leading-[20px]" show-on-truncate-only>
<template #title> {{ charsetOptionsMap[file.encoding]?.sortLabel ?? '' }}</template>
{{ charsetOptionsMap[file.encoding]?.sortLabel?.replace('Windows', 'Win') ?? '' }}
</NcTooltip>
<GeneralIcon
icon="chevronDown"
class="flex-none transform"
:class="{
'rotate-180': visible,
}"
/>
</NcButton>
</template>
<template #overlay="{ visible, onChange: onChangeVisibility }">
<NcList
v-model:value="file.encoding"
:open="visible"
:list="charsetOptions"
search-input-placeholder="Search"
option-label-key="sortLabel"
option-value-key="value"
class="!w-full"
variant="small"
@update:open="onChangeVisibility"
>
</NcList>
</template>
</NcDropdown>
</a-form-item>
<NcButton type="text" size="xsmall" class="flex-shrink" @click="actions?.remove?.()">
<GeneralIcon icon="deleteListItem" />
</NcButton>
</template>
<span class="inline-block truncate w-full">
{{ file.name }}
<template v-else>
<NcTooltip
:key="progressMsgNew[file.name] || progressMsg"
class="!max-w-[120px] min-w-[120p] !leading-[18px] truncate"
show-on-truncate-only
>
<template #title> {{ progressMsgNew[file.name] || progressMsg }}</template>
<span class="!text-small text-nc-content-gray-muted">
{{ progressMsgNew[file.name] || progressMsg }}
</span>
</NcTooltip>
<GeneralLoader class="flex text-nc-content-brand" size="medium" />
</template>
</div>
</template>
</a-upload-dragger>
<a-alert
v-if="showMaxFileLimitError"
class="!rounded-lg !bg-transparent !border-nc-border-gray-medium !p-4 !w-full !mt-5"
>
<template #message>
<div class="flex flex-row items-center gap-2 mb-1">
<GeneralIcon icon="alertTriangleSolid" class="text-nc-content-orange-medium w-6 h-6" />
<span class="font-weight-700 text-sm flex-1">{{ $t('msg.warning.reachedUploadLimit') }}</span>
<NcButton size="xsmall" type="text" @click="showMaxFileLimitError = false">
<GeneralIcon icon="close" class="text-nc-content-gray-subtle" />
</NcButton>
</div>
</template>
<template #description>
<div class="text-nc-content-gray-muted text-small leading-5 ml-8">
{{
$t(
`msg.warning.${
maxFileUploadLimit > 1
? 'youCanOnlyUploadMaxLimitFilesAtATimePlural'
: 'youCanOnlyUploadMaxLimitFilesAtATime'
}`,
{
limit: maxFileUploadLimit,
type: $t(`labels.${importType}`),
},
)
}}
</div>
</template>
</a-alert>
</div>
</a-tab-pane>
<a-tab-pane v-if="!isImportTypeJson" :key="ImportTypeTabs.uploadFromUrl" :disabled="preImportLoading" class="!h-full">
<template #tab>
<div class="flex gap-2 items-center">
<span class="text-sm">{{ $t('labels.addFromUrl') }} </span>
</div>
</template>
<div class="relative mt-5 mb-1 px-1">
<a-form :model="importState" name="quick-import-url-form" layout="vertical" class="!my-0">
<a-form-item v-bind="validateInfos.url" :required="false" class="!my-0 quick-import-url-form">
<template #label>
<div class="flex items-center space-x-2 w-full">
<span class="flex-1 text-nc-content-gray text-sm">
{{ importMeta.urlInputLabel }}
</span>
<template v-if="preImportLoading">
<NcTooltip
:key="progressMsgNew[importState.url.split('/').pop() ?? ''] || progressMsg"
class="!max-w-1/2 min-w-[120p] !leading-[18px] truncate"
show-on-truncate-only
>
<template #title>
{{ progressMsgNew[importState.url.split('/').pop() ?? ''] || progressMsg }}</template
>
<span class="!text-small text-nc-content-gray-muted">
{{ progressMsgNew[importState.url.split('/').pop() ?? ''] || progressMsg }}
</span>
</NcTooltip>
<GeneralLoader class="flex text-nc-content-brand" size="medium" />
</template>
</div>
</template>
<a-input
v-model:value="importState.url"
class="!rounded-md"
placeholder="Paste file link here..."
:disabled="preImportLoading"
/>
</a-form-item>
</a-form>
</div>
</a-tab-pane>
<a-tab-pane v-if="isImportTypeJson" :key="ImportTypeTabs.uploadJSON" :disabled="preImportLoading" class="!h-full">
<template #tab>
<div class="flex gap-2 items-center">
<span class="text-sm">{{ $t('labels.enterJson') }} </span>
</div>
</template>
<div class="relative mt-5">
<div class="flex items-end gap-2">
<label class="text-nc-content-gray text-sm"> Enter Json </label>
<div class="flex-1" />
<template v-if="preImportLoading">
<NcTooltip
:key="progressMsgNew[importState.url.split('/').pop() ?? ''] || progressMsg"
class="!max-w-1/2 min-w-[120p] !leading-[25px] truncate"
show-on-truncate-only
>
<template #title> {{ progressMsgNew[importState.url.split('/').pop() ?? ''] || progressMsg }}</template>
<span class="!text-small text-nc-content-gray-muted">
{{ progressMsgNew[importState.url.split('/').pop() ?? ''] || progressMsg }}
</span>
</NcTooltip>
</div>
<div class="text-[14px] text-[#565B66] mt-1 font-weight-500">
{{ getReadableFileSize(file.size) }}
</div>
<GeneralLoader class="flex text-nc-content-brand" size="medium" />
</template>
<NcButton v-else type="text" size="xsmall" class="!px-2" @click="formatJson()"> Format </NcButton>
</div>
<div class="resize-y overflow-y-auto h-30 min-h-30 max-h-[400px] !border-1 !rounded-lg !mt-2">
<LazyMonacoEditor
ref="refMonacoEditor"
class="nc-import-monaco-editor h-full"
:auto-focus="false"
hide-minimap
:monaco-config="{
lineNumbers: 'on',
}"
:model-value="temporaryJson"
@update:model-value="handleJsonChange($event)"
/>
</div>
<div v-if="jsonErrorText || refMonacoEditor?.error" class="text-nc-content-red-medium text-small mt-2">
{{ jsonErrorText || refMonacoEditor?.error }}
</div>
<a-form-item class="flex-1 !my-0 max-w-[120px] min-w-[120px]">
<NcSelect
v-model:value="file.encoding"
:filter-option="filterOption"
class="w-[120px] max-w-[120px] nc-select-shadow"
show-search
>
<a-select-option v-for="enc of charsetOptions" :key="enc.label" :value="enc.value">
<div class="w-full flex items-center gap-2">
<NcTooltip class="flex-1 truncate" show-on-truncate-only>
<template #title>{{ enc.label }}</template>
<span>{{ enc.label }}</span>
</NcTooltip>
<component
:is="iconMap.check"
v-if="file.encoding === enc.value"
id="nc-selected-item-icon"
class="text-nc-content-purple-medium w-4 h-4"
/>
</div>
</a-select-option>
</NcSelect>
</a-form-item>
<nc-button type="text" size="xsmall" class="flex-shrink" @click="actions?.remove?.()">
<GeneralIcon icon="deleteListItem" />
</nc-button>
</div>
</template>
</a-upload-dragger>
<div v-if="isImportTypeJson" class="my-5">
<div class="flex items-end gap-2">
<label> Enter Json </label>
<div class="flex-1" />
<NcButton type="text" size="xsmall" class="!px-2" @click="formatJson()"> Format </NcButton>
</div>
<LazyMonacoEditor
ref="refMonacoEditor"
class="nc-import-monaco-editor h-30 !border-1 !rounded-lg !mt-2"
:auto-focus="false"
hide-minimap
:read-only="isPreImportFileFilled"
:monaco-config="{
lineNumbers: 'on',
}"
:model-value="temporaryJson"
@update:model-value="handleJsonChange($event)"
/>
<a-alert v-if="jsonErrorText && !isPreImportFileFilled" type="error" class="!rounded-lg !mt-2 !border-none !p-3">
<template #message>
<div class="flex flex-row items-center gap-2 mb-2">
<GeneralIcon icon="ncAlertCircleFilled" class="text-red-500 w-4 h-4" />
<span class="font-weight-700 text-[14px]">Json Error</span>
</div>
</template>
<template #description>
<div class="text-gray-500 text-[13px] leading-5 ml-6">
{{ jsonErrorText }}
</div>
</template>
</a-alert>
</div>
<a-form v-if="!isImportTypeJson" :model="importState" name="quick-import-url-form" layout="vertical" class="mb-0 !mt-5">
<a-form-item :label="importMeta.urlInputLabel" v-bind="validateInfos.url" :required="false">
<a-input
v-model:value="importState.url"
class="!rounded-md"
placeholder="Paste file link here..."
:disabled="isPreImportFileFilled"
/>
</a-form-item>
</a-form>
</a-tab-pane>
</NcTabs>
</div>
</div>
<a-alert v-if="importError" class="!rounded-lg !bg-transparent !border-nc-border-gray-medium !p-4 !w-full !mt-5">
<template #message>
<div class="flex flex-row items-center gap-2 mb-1">
<GeneralIcon icon="ncAlertCircleFilled" class="text-nc-content-red-dark w-6 h-6" />
<span class="font-weight-700 text-sm flex-1">{{ $t('msg.error.importError') }}</span>
<NcButton size="xsmall" type="text" @click="handleResetImportError">
<GeneralIcon icon="close" class="text-nc-content-gray-subtle" />
</NcButton>
</div>
</template>
<template #description>
<div class="text-nc-content-gray-muted text-small leading-5 ml-8 line-clamp-3">
{{ importError }}
</div>
</template>
</a-alert>
<div v-if="!templateEditorModal" class="mt-5">
<nc-button type="text" size="small" @click="collapseKey = !collapseKey ? 'advanced-settings' : ''">
<NcButton type="text" size="small" @click="collapseKey = !collapseKey ? 'advanced-settings' : ''">
{{ $t('title.advancedSettings') }}
<GeneralIcon
icon="chevronDown"
class="ml-2 !transition-all !transform"
:class="{ '!rotate-180': collapseKey === 'advanced-settings' }"
/>
</nc-button>
</NcButton>
<a-collapse v-model:active-key="collapseKey" ghost class="nc-import-collapse">
<a-collapse
v-model:active-key="collapseKey"
ghost
class="nc-import-collapse"
:class="{
'pointer-events-none': preImportLoading || importLoading,
}"
>
<a-collapse-panel key="advanced-settings">
<a-form-item v-if="isImportTypeCsv || IsImportTypeExcel" class="!my-2 nc-dense-checkbox-container">
<a-checkbox v-model:checked="importState.parserConfig.firstRowAsHeaders">
<NcCheckbox v-model:checked="importState.parserConfig.firstRowAsHeaders">
<span class="caption">{{ $t('labels.firstRowAsHeaders') }}</span>
</a-checkbox>
</NcCheckbox>
</a-form-item>
<a-form-item v-if="isImportTypeJson" class="!my-2 nc-dense-checkbox-container">
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested">
<NcCheckbox v-model:checked="importState.parserConfig.normalizeNested">
<span class="caption">{{ $t('labels.flattenNested') }}</span>
</a-checkbox>
</NcCheckbox>
</a-form-item>
<a-form-item v-if="!importDataOnly" class="!my-2 nc-dense-checkbox-container">
<a-checkbox v-model:checked="importState.parserConfig.shouldImportData">{{ $t('labels.importData') }} </a-checkbox>
<NcCheckbox v-model:checked="importState.parserConfig.shouldImportData">{{ $t('labels.importData') }} </NcCheckbox>
</a-form-item>
</a-collapse-panel>
</a-collapse>
</div>
</a-spin>
</div>
<template #footer>
<div class="flex items-center gap-2 pt-3">
<nc-button v-if="templateEditorModal" key="back" type="text" size="small" @click="templateEditorModal = false">
{{ $t('general.back') }}
</nc-button>
<nc-button
v-else
key="cancel"
<NcButton
v-if="templateEditorModal"
key="back"
type="text"
size="small"
@click="
() => {
dialogShow = false
emit('back')
}
"
:disabled="importLoading"
@click="templateEditorModal = false"
>
<GeneralIcon icon="chevronLeft" class="mr-1" />
{{ $t('general.back') }}
</nc-button>
</NcButton>
<NcButton v-else key="cancel" type="text" size="small" @click="onClickCancel">
<GeneralIcon v-if="showBackBtn" icon="chevronLeft" class="mr-1" />
{{ showBackBtn ? $t('general.back') : $t('general.cancel') }}
</NcButton>
<div class="flex-1" />
<nc-button
<NcButton
v-if="!templateEditorModal"
key="pre-import"
size="small"
class="nc-btn-import"
:loading="preImportLoading"
:disabled="disablePreImportButton"
:disabled="disablePreImportButton || preImportLoading"
@click="handlePreImport"
>
{{ importDataOnly ? $t('activity.upload') : $t('activity.import') }}
</nc-button>
{{ importBtnText }}
</NcButton>
<nc-button
<NcButton
v-else
key="import"
size="small"
:loading="importLoading"
:disabled="disableImportButton"
:disabled="disableImportButton || importLoading"
@click="handleImport"
>
{{ importDataOnly ? $t('activity.upload') : $t('activity.import') }}
</nc-button>
{{ importBtnText }}
</NcButton>
</div>
</template>
</a-modal>
@@ -840,7 +1113,48 @@ span:has(> .nc-modern-drag-import) {
:deep(.nc-modern-drag-import:not(.ant-upload-disabled)) {
@apply bg-white hover:bg-gray-50;
}
:deep(.nc-modern-drag-import.hidden + .ant-upload-list) {
@apply !mb-0;
}
:deep(.nc-dense-checkbox-container .ant-form-item-control-input) {
min-height: unset !important;
}
.nc-quick-import-tabs {
:deep(.ant-tabs-nav) {
@apply !pl-0;
}
:deep(.ant-tabs-tab) {
@apply px-0 pt-0 pb-2;
&.ant-tabs-tab-active {
@apply font-medium;
}
}
:deep(.ant-tabs-tab + .ant-tabs-tab) {
@apply ml-4;
}
.tab-title,
:deep(.ant-tabs-tab-btn) {
@apply px-2 text-nc-content-gray-subtle2 rounded-md hover:bg-gray-100 transition-colors;
span {
@apply text-small !leading-[24px];
}
}
:deep(.ant-tabs-tab-disabled) {
.ant-tabs-tab-btn,
.tab-title {
@apply text-nc-content-gray-muted hover:bg-transparent;
}
}
:deep(.quick-import-url-form label) {
@apply w-full;
}
}
</style>

View File

@@ -11,12 +11,12 @@ const displayName = computed(() => {
</script>
<template>
<div class="flex flex-row items-center gap-x-2 h-12.5 p-2">
<GeneralUserIcon size="base" :user="user" class="!text-[0.65rem]" />
<div class="flex flex-row items-center gap-x-3 h-12.5 p-2">
<GeneralUserIcon size="base" :user="user" />
<div class="flex flex-col justify-center flex-grow">
<div class="flex flex-col">
<span class="capitalize font-weight-medium">{{ displayName }}</span>
<span class="text-xs">{{ user.email }}</span>
<span class="capitalize font-semibold text-nc-content-gray">{{ displayName }}</span>
<span class="text-xs text-nc-content-gray-subtle2">{{ user.email }}</span>
</div>
</div>
<slot name="append"></slot>

View File

@@ -99,18 +99,18 @@ const inputEl = (el: HTMLInputElement) => {
<template>
<NcModal v-model:visible="vModel" wrap-class-name="nc-modal-re-assign" width="448px">
<div class="mb-5">
<div class="flex text-base font-bold mb-2">Re-assign this view</div>
<div class="flex text-nc-content-gray-subtle">
Once reassigned, current owner will no longer be able to edit the view configuration.
<div class="flex text-base font-bold mb-2 text-nc-content-gray-emphasis">{{ $t('labels.reAssignThisView') }}</div>
<div class="flex text-sm text-nc-content-gray-subtle">
{{ $t('title.reAssignViewModalSubtitle') }}
</div>
</div>
<div class="mb-5">
<div class="mb-2">Current owner</div>
<UserItem :user="currentOwner" class="bg-nc-bg-gray-light rounded-lg" />
<div class="mb-2 text-nc-content-gray">{{ $t('labels.currentOwner') }}</div>
<UserItem :user="currentOwner" class="bg-nc-bg-gray-light rounded-lg px-4" />
</div>
<div class="mb-5">
<div class="mb-2">New owner</div>
<div class="mb-2 text-nc-content-gray">{{ $t('labels.newOwner') }}</div>
<div
class="rounded-lg border-1"
:class="{
@@ -120,7 +120,7 @@ const inputEl = (el: HTMLInputElement) => {
<UserItem
v-if="selectedUser && !userSelectMenu"
:user="selectedUser"
class="cursor-pointer"
class="cursor-pointer px-3"
@click="userSelectMenu = true"
>
<template #append>
@@ -128,13 +128,13 @@ const inputEl = (el: HTMLInputElement) => {
</template>
</UserItem>
<div v-else class="flex flex-row items-center gap-x-2 h-12.5 p-2 nc-list-user-item">
<GeneralIcon icon="search" class="text-gray-500 ml-2" />
<div v-else class="flex flex-row items-center h-12.5 p-2 nc-list-user-item">
<GeneralIcon icon="search" class="text-nc-content-gray-muted ml-3 flex-none" />
<input
:ref="inputEl"
v-model="searchQuery"
placeholder="Search User to assign..."
class="border-0 px-2.5 outline-none nc-search-input"
class="border-0 px-2 outline-none nc-search-input flex-1"
/>
</div>
@@ -142,7 +142,7 @@ const inputEl = (el: HTMLInputElement) => {
<UserItem
v-for="user of filterdBaseUsers"
:key="user.id"
class="cursor-pointer hover:(bg-gray-100) nc-list-user-item"
class="cursor-pointer hover:(bg-gray-100) px-3 nc-list-user-item"
:class="{ 'bg-gray-100': selectedUser === user }"
:user="user"
@click="selectUser(user)"
@@ -151,7 +151,7 @@ const inputEl = (el: HTMLInputElement) => {
</div>
<div v-if="!filterdBaseUsers?.length" class="h-25 p-2 text-gray-400 text-sm flex items-center justify-center">
No base users found
{{ $t('placeholder.noBaseUsersFound') }}
</div>
</div>
</div>

View File

@@ -780,9 +780,9 @@ const handleRefreshOnError = () => {
</NcButton>
<div v-else></div>
<div class="flex gap-2 items-center">
<NcButton type="secondary" size="small" :disabled="creating || isAiSaving" @click="dialogShow = false">{{
$t('general.cancel')
}}</NcButton>
<NcButton type="secondary" size="small" :disabled="creating || isAiSaving" @click="dialogShow = false">
{{ $t('general.cancel') }}
</NcButton>
<NcButton
v-if="!aiMode"

View File

@@ -133,35 +133,50 @@ const isEaster = ref(false)
:keyboard="!isLoading"
centered
wrap-class-name="nc-modal-table-duplicate"
:mask-style="{
'background-color': 'rgba(0, 0, 0, 0.08)',
}"
:footer="null"
class="!w-[30rem]"
@keydown.esc="dialogShow = false"
>
<div>
<div class="font-medium text-lg text-gray-800 self-center" @dblclick="isEaster = !isEaster">
{{ $t('general.duplicate') }} {{ $t('objects.table') }}
<div class="text-base text-nc-content-gray-emphasis leading-6 font-bold self-center" @dblclick="isEaster = !isEaster">
{{ $t('general.duplicate') }} {{ $t('objects.table') }} "{{ table.title }}"
</div>
<div class="mt-5">{{ $t('msg.warning.duplicateTable') }}</div>
<div class="mt-5 flex gap-3 flex-col">
<div
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeData = !options.includeData"
>
<NcSwitch :checked="options.includeData" />
{{ $t('labels.includeRecords') }}
</div>
<div
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeViews = !options.includeViews"
>
<NcSwitch :checked="options.includeViews" />
{{ $t('labels.includeView') }}
</div>
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div>
<a-divider class="!m-0 !p-0 !my-2" />
<div class="text-xs p-2">
<a-checkbox v-model:checked="options.includeData" :disabled="isLoading">{{ $t('labels.includeData') }}</a-checkbox>
<a-checkbox v-model:checked="options.includeViews" :disabled="isLoading">{{ $t('labels.includeView') }}</a-checkbox>
<a-checkbox v-show="isEaster" v-model:checked="options.includeHooks" :disabled="isLoading">
<div
v-show="isEaster"
class="flex gap-3 cursor-pointer leading-5 text-nc-content-gray font-medium items-center"
@click="options.includeHooks = !options.includeHooks"
>
<NcSwitch :checked="options.includeHooks" />
{{ $t('labels.includeWebhook') }}
</a-checkbox>
</div>
</div>
</div>
<div class="flex flex-row gap-x-2 mt-2.5 pt-2.5 justify-end">
<div class="flex flex-row gap-x-2 mt-5 justify-end">
<NcButton v-if="!isLoading" key="back" type="secondary" size="small" @click="dialogShow = false">{{
$t('general.cancel')
}}</NcButton>
<NcButton key="submit" v-e="['a:table:duplicate']" type="primary" size="small" :loading="isLoading" @click="_duplicate"
>{{ $t('general.confirm') }}
<NcButton key="submit" v-e="['a:table:duplicate']" type="primary" size="small" :loading="isLoading" @click="_duplicate">
Duplicate Table
</NcButton>
</div>
</GeneralModal>

View File

@@ -68,7 +68,7 @@ interface Form {
fk_from_column_id: string
fk_to_column_id: string | null // for ee only
}>
fk_cover_image_col_id: string | null
fk_cover_image_col_id: string | null | undefined
}
type AiSuggestedViewType = SerializedAiViewType & {
@@ -125,7 +125,7 @@ const form = reactive<Form>({
fk_grp_col_id: null,
fk_geo_data_col_id: null,
calendar_range: props.calendarRange || [],
fk_cover_image_col_id: null,
fk_cover_image_col_id: undefined,
description: props.description || '',
})
@@ -466,7 +466,7 @@ onMounted(async () => {
} else if (viewSelectFieldOptions.value.length > 1 && !form.copy_from_id) {
form.fk_cover_image_col_id = viewSelectFieldOptions.value[1].value as string
} else {
form.fk_cover_image_col_id = null
form.fk_cover_image_col_id = undefined
}
}
@@ -498,7 +498,7 @@ onMounted(async () => {
} else if (viewSelectFieldOptions.value.length > 1 && !form.copy_from_id) {
form.fk_cover_image_col_id = viewSelectFieldOptions.value[1].value as string
} else {
form.fk_cover_image_col_id = null
form.fk_cover_image_col_id = undefined
}
}

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { CSSProperties } from '@vue/runtime-dom'
const props = withDefaults(
defineProps<{
visible: boolean
@@ -6,6 +8,7 @@ const props = withDefaults(
size?: 'small' | 'medium' | 'large' | 'xl'
destroyOnClose?: boolean
maskClosable?: boolean
maskStyle?: CSSProperties
closable?: boolean
keyboard?: boolean
}>(),
@@ -77,6 +80,7 @@ const visible = useVModel(props, 'visible', emits)
:closable="closable"
:keyboard="keyboard"
wrap-class-name="nc-modal-wrapper"
:mask-style="maskStyle"
:footer="null"
:destroy-on-close="destroyOnClose"
:mask-closable="maskClosable"

View File

@@ -12,7 +12,7 @@ const props = withDefaults(
iconType: IconType | string
}
hideLabel?: boolean
size?: 'small' | 'medium' | 'large' | 'xlarge'
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'middle'
isRounded?: boolean
iconBgColor?: string
}>(),
@@ -80,8 +80,10 @@ const size = computed(() => props.size || 'medium')
:class="{
'min-w-4 w-4 h-4 rounded': size === 'small',
'min-w-6 w-6 h-6 rounded-md': size === 'medium',
'min-w-8 w-6 h-8 rounded-md': size === 'middle',
'min-w-10 w-10 h-10 rounded-lg !text-base': size === 'large',
'min-w-16 w-16 h-16 rounded-lg !text-4xl': size === 'xlarge',
'min-w-8 w-6 h-8 rounded-md': size === 'middle',
'!rounded-[50%]': props.isRounded,
}"
:style="{
@@ -143,6 +145,7 @@ const size = computed(() => props.size || 'medium')
:class="{
'text-white': isColorDark(workspaceColor),
'text-black': !isColorDark(workspaceColor),
'text-[8px]': size === 'small',
}"
>
{{ workspace?.title?.slice(0, 2) }}

View File

@@ -33,29 +33,67 @@ const { modelValue, readOnly } = toRefs(props)
const { hideMinimap, lang, validate, disableDeepCompare, autoFocus, monacoConfig, monacoCustomTheme, placeholder } = props
let isInitialLoad = false
const vModel = computed<string>({
get: () => {
if (typeof modelValue.value === 'object') {
return JSON.stringify(modelValue.value, null, 2)
} else {
return modelValue.value ?? ''
const value = modelValue.value
// If value is null or undefined, return null
if (ncIsNull(value) || ncIsUndefined(value)) {
return null
}
// If value is not a string, convert it to a formatted JSON string
if (typeof value !== 'string') {
return JSON.stringify(value, null, 2)
}
// Handle JSON-specific cases on the initial load
if (lang === 'json' && !isInitialLoad) {
try {
// if null string, return '"null"'
if (value.trim() === 'null') {
return '"null"'
}
// If value is a valid JSON string, leave it as is
JSON.parse(value)
} catch (e) {
// If value is an invalid JSON string, convert it to a JSON string format
return JSON.stringify(value)
} finally {
// Ensure this block runs only once during the initial load
isInitialLoad = true
}
}
return value
},
set: (newVal: string | Record<string, any>) => {
if (typeof modelValue.value === 'object') {
try {
emits('update:modelValue', typeof newVal === 'object' ? newVal : JSON.parse(newVal))
} catch (e) {
console.error(e)
try {
// if the new value is null, emit null
if (newVal === 'null') {
emits('update:modelValue', null)
}
} else {
emits('update:modelValue', newVal)
// If the current value is an object, attempt to parse and update
else if (typeof modelValue.value === 'object') {
// If the new value is 'null', emit null
const parsedValue = typeof newVal === 'object' ? newVal : JSON.parse(newVal)
emits('update:modelValue', parsedValue)
} else {
// Directly emit new value if it's not an object
emits('update:modelValue', newVal)
}
} catch (e) {
console.error('Failed to parse JSON:', e)
}
},
})
const isValid = ref(true)
const error = ref('')
const root = ref<HTMLDivElement>()
let editor: MonacoEditor.IStandaloneCodeEditor
@@ -72,6 +110,7 @@ const format = (space = monacoConfig.tabSize || 2) => {
defineExpose({
format,
isValid,
error,
})
onMounted(async () => {
@@ -121,6 +160,7 @@ onMounted(async () => {
editor.onDidChangeModelContent(async () => {
try {
isValid.value = true
error.value = ''
if (disableDeepCompare || lang !== 'json') {
vModel.value = editor.getValue()
@@ -131,7 +171,9 @@ onMounted(async () => {
}
} catch (e) {
isValid.value = false
console.log(e)
const err = await extractSdkResponseErrorMsg(e)
error.value = err
console.log(err)
}
})

View File

@@ -34,6 +34,8 @@ const autoClose = computed(() => props.autoClose)
const visible = useVModel(props, 'visible', emits)
const localIsVisible = ref<boolean | undefined>(props.visible)
const overlayClassNameComputed = computed(() => {
let className = 'nc-dropdown bg-white rounded-lg border-1 border-gray-200 shadow-lg'
if (overlayClassName.value) {
@@ -57,13 +59,25 @@ onClickOutside(overlayWrapperDomRef, () => {
visible.value = false
})
const onVisibleUpdate = (event: any) => {
const onVisibleUpdate = (event: boolean) => {
localIsVisible.value = event
if (visible !== undefined) {
visible.value = event
} else {
emits('update:visible', event)
}
}
watch(
visible,
(newValue) => {
if (newValue === localIsVisible.value) return
localIsVisible.value = visible.value
},
{ immediate: true },
)
</script>
<template>
@@ -76,10 +90,10 @@ const onVisibleUpdate = (event: any) => {
:overlay-style="overlayStyle"
@update:visible="onVisibleUpdate"
>
<slot />
<slot :visible="localIsVisible" :on-change="onVisibleUpdate" />
<template #overlay>
<slot ref="overlayWrapperDomRef" name="overlay" />
<slot ref="overlayWrapperDomRef" name="overlay" :visible="localIsVisible" :on-change="onVisibleUpdate" />
</template>
</a-dropdown>
</template>

View File

@@ -50,6 +50,7 @@ export interface NcListProps {
* @default 38
*/
itemHeight?: number
variant?: 'default' | 'small' | 'medium'
/** Custom filter function for list items */
filterOption?: (input: string, option: NcListItemType, index: Number) => boolean
/**
@@ -82,7 +83,7 @@ const props = withDefaults(defineProps<NcListProps>(), {
showSelectedOption: true,
optionValueKey: 'value',
optionLabelKey: 'label',
itemHeight: 38,
variant: 'default',
isMultiSelect: false,
minItemsForSearch: 4,
containerClassName: '',
@@ -102,6 +103,20 @@ const { optionValueKey, optionLabelKey } = props
const { closeOnSelect, showSelectedOption, containerClassName, itemClassName } = toRefs(props)
const itemHeight = computed(() => {
if (!props.itemHeight) {
if (props.variant === 'medium') {
return 30
}
if (props.variant === 'small') {
return 26
}
}
return 38
})
const slots = useSlots()
const listRef = ref<HTMLDivElement>()
@@ -146,7 +161,7 @@ const {
wrapperProps,
scrollTo,
} = useVirtualList(list, {
itemHeight: props.itemHeight + 2,
itemHeight: itemHeight.value + 2,
})
/**
@@ -197,7 +212,6 @@ const handleResetHoverEffect = (clearActiveOption = false, newActiveIndex?: numb
*/
const handleSelectOption = (option: NcListItemType, index?: number) => {
if (!ncIsObject(option) || !(optionValueKey in option) || option.ncItemDisabled) return
if (index !== undefined) {
activeOptionIndex.value = index
}
@@ -338,7 +352,7 @@ watch(
<div
ref="listRef"
tabindex="0"
class="flex flex-col pt-2 w-64 !focus:(shadow-none outline-none)"
class="flex flex-col nc-list-root pt-2 w-64 !focus:(shadow-none outline-none)"
@keydown.arrow-down.prevent="onArrowDown"
@keydown.arrow-up.prevent="onArrowUp"
@keydown.enter.prevent="handleSelectOption(list[activeOptionIndex])"
@@ -351,6 +365,9 @@ watch(
v-model:value="searchQuery"
:placeholder="searchInputPlaceholder"
class="nc-toolbar-dropdown-search-field-input !pl-2 !pr-1.5 flex-1"
:class="{
'!pt-0': variant === 'small',
}"
allow-clear
:bordered="false"
@keydown.enter.stop="handleKeydownEnter"
@@ -376,7 +393,7 @@ watch(
<NcTooltip
v-for="{ data: option, index: idx } in virtualList"
:key="idx"
class="flex items-center gap-2 w-full py-2 px-2 rounded-md my-[2px] first-of-type:mt-0 last-of-type:mb-0"
class="flex items-center gap-2 nc-list-item w-full px-2 rounded-md my-[2px] first-of-type:mt-0 last-of-type:mb-0"
:class="[
`nc-list-option-${idx}`,
{
@@ -386,6 +403,9 @@ watch(
'bg-gray-100 nc-list-option-active': !option?.ncItemDisabled && activeOptionIndex === idx,
'opacity-60 cursor-not-allowed': option?.ncItemDisabled,
'hover:bg-gray-100 cursor-pointer': !option?.ncItemDisabled,
'py-2': variant === 'default',
'py-[5px]': variant === 'medium',
'py-[3px]': variant === 'small',
},
`${itemClassName}`,
]"

View File

@@ -57,6 +57,7 @@ const onChange = (e: boolean, updateValue = false) => {
:class="{
'size-xsmall': size === 'xsmall',
'size-xxsmall': size === 'xxsmall',
'size-small': size === 'small',
}"
:loading="loading"
v-bind="$attrs"
@@ -81,6 +82,28 @@ const onChange = (e: boolean, updateValue = false) => {
</template>
<style lang="scss" scoped>
.size-small {
@apply h-4 min-w-[28px] leading-[14px];
:deep(.ant-switch-handle) {
@apply h-[12px] w-[12px] top-[2px] left-[calc(100%_-_26px)];
}
:deep(.ant-switch-inner) {
@apply !mr-[5px] !ml-[18px] !my-0;
}
&.ant-switch-checked {
:deep(.ant-switch-handle) {
@apply left-[calc(100%_-_14px)];
}
:deep(.ant-switch-inner) {
@apply !mr-[18px] !ml-[5px];
}
}
}
.size-xsmall {
@apply h-3.5 min-w-[26px] leading-[14px];

View File

@@ -78,26 +78,34 @@ const updateOrderBy = (field: string) => {
* We are using 2 different table tag to make header sticky,
* so it's imp to keep header cell and body cell width same
*/
const handleUpdateCellWidth = () => {
if (!tableHeader.value || !tableHeadWidth.value) return
nextTick(() => {
const headerCells = tableHeader.value?.querySelectorAll('th > div')
if (headerCells && headerCells.length) {
headerCells.forEach((el, i) => {
headerCellWidth.value[i] = el.getBoundingClientRect().width || undefined
})
}
})
}
watch(
tableHeadWidth,
[tableHeader, tableHeadWidth],
() => {
if (!tableHeader.value || !tableHeadWidth.value) return
nextTick(() => {
const headerCells = tableHeader.value?.querySelectorAll('th > div')
if (headerCells && headerCells.length) {
headerCells.forEach((el, i) => {
headerCellWidth.value[i] = el.getBoundingClientRect().width || undefined
})
}
})
handleUpdateCellWidth()
},
{
immediate: true,
},
)
onMounted(() => {
handleUpdateCellWidth()
})
useEventListener(tableWrapper, 'scroll', () => {
const stickyHeaderCell = tableWrapper.value?.querySelector('th:nth-of-type(1)')
const nonStickyHeaderFirstCell = tableWrapper.value?.querySelector('th:nth-of-type(2)')

View File

@@ -17,7 +17,7 @@ const { toggleRead } = notificationStore
<div class="select-none" @click="toggleRead(item, item.is_read)">
<NotificationItemWelcome v-if="item.type === AppEvents.WELCOME" :item="item" />
<NotificationItemProjectInvite v-else-if="item.type === AppEvents.PROJECT_INVITE" :item="item" />
<NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_INVITE" :item="item" />
<NotificationItemWorkspaceInvite v-else-if="item.type === AppEvents.WORKSPACE_USER_INVITE" :item="item" />
<NotificationItemMentionEvent v-else-if="['mention'].includes(item.type)" :item="item" />
<NotificationItemRowMentionEvent v-else-if="AppEvents.ROW_USER_MENTION === item.type" :item="item" />
<span v-else />

View File

@@ -12,8 +12,6 @@ const source = toRef(props, 'source')
const visible = useVModel(props, 'visible', emits)
const transitionName = ref<string | undefined>(undefined)
const { $e } = useNuxtApp()
const { isFeatureEnabled } = useBetaFeatureToggle()
@@ -24,7 +22,6 @@ async function openAirtableImportDialog(baseId?: string, sourceId?: string) {
$e('a:actions:import-airtable')
const isOpen = ref(true)
transitionName.value = 'dissolve'
await nextTick()
visible.value = false
@@ -34,7 +31,7 @@ async function openAirtableImportDialog(baseId?: string, sourceId?: string) {
'baseId': baseId,
'sourceId': sourceId,
'onUpdate:modelValue': closeDialog,
'transition': 'dissolve',
'showBackBtn': true,
'onBack': () => {
visible.value = true
},
@@ -53,7 +50,6 @@ async function openNocoDbImportDialog(baseId?: string) {
// $e('a:actions:import-nocodb')
const isOpen = ref(true)
transitionName.value = 'dissolve'
await nextTick()
visible.value = false
@@ -62,7 +58,7 @@ async function openNocoDbImportDialog(baseId?: string) {
'modelValue': isOpen,
'baseId': baseId,
'onUpdate:modelValue': closeDialog,
'transition': 'dissolve',
'showBackBtn': true,
'onBack': () => {
visible.value = true
},
@@ -81,7 +77,6 @@ async function openQuickImportDialog(type: 'csv' | 'excel' | 'json') {
$e(`a:actions:import-${type}`)
const isOpen = ref(true)
transitionName.value = 'dissolve'
await nextTick()
visible.value = false
@@ -92,7 +87,7 @@ async function openQuickImportDialog(type: 'csv' | 'excel' | 'json') {
'baseId': source.value.base_id,
'sourceId': source.value.id,
'onUpdate:modelValue': closeDialog,
'transition': 'dissolve',
'showBackBtn': true,
'onBack': () => {
visible.value = true
},
@@ -117,7 +112,7 @@ const onClick = (type: 'airtable' | 'csv' | 'excel' | 'json' | 'nocodb') => {
</script>
<template>
<GeneralModal v-model:visible="visible" width="448px" class="!top-[25vh]" :transition-name="transitionName">
<GeneralModal v-model:visible="visible" width="448px" class="!top-[25vh]">
<div class="flex flex-col px-6 pt-6 pb-9">
<div class="flex items-center gap-3 mb-6">
<div class="text-base font-weight-700">{{ $t('labels.importDataFrom') }}</div>
@@ -125,27 +120,27 @@ const onClick = (type: 'airtable' | 'csv' | 'excel' | 'json' | 'nocodb') => {
<NcMenu class="border-1 divide-y-1 nc-import-items-menu overflow-clip">
<NcMenuItem @click="onClick('airtable')">
<GeneralIcon icon="importAirtable" class="w-5 h-5" />
<span class="ml-1 text-[13px] font-weight-700"> Airtable </span>
<span class="ml-1 text-[13px] font-weight-700"> {{ $t('labels.airtable') }} </span>
<GeneralIcon icon="chevronRight" class="ml-auto text-lg" />
</NcMenuItem>
<NcMenuItem @click="onClick('csv')">
<GeneralIcon icon="importCsv" class="w-5 h-5" />
<span class="ml-1 text-[13px] font-weight-700"> CSV </span>
<span class="ml-1 text-[13px] font-weight-700"> {{ $t('labels.csv') }} </span>
<GeneralIcon icon="chevronRight" class="ml-auto text-lg" />
</NcMenuItem>
<NcMenuItem @click="onClick('json')">
<GeneralIcon icon="importJson" class="w-5 h-5" />
<span class="ml-1 text-[13px] font-weight-700"> Json </span>
<span class="ml-1 text-[13px] font-weight-700"> {{ $t('labels.jsonCapitalized') }} </span>
<GeneralIcon icon="chevronRight" class="ml-auto text-lg" />
</NcMenuItem>
<NcMenuItem @click="onClick('excel')">
<GeneralIcon icon="importExcel" class="w-5 h-5" />
<span class="ml-1 text-[13px] font-weight-700"> Excel </span>
<span class="ml-1 text-[13px] font-weight-700"> {{ $t('labels.excel') }} </span>
<GeneralIcon icon="chevronRight" class="ml-auto text-lg" />
</NcMenuItem>
<NcMenuItem v-if="isFeatureEnabled(FEATURE_FLAG.IMPORT_FROM_NOCODB)" @click="onClick('nocodb')">
<GeneralIcon icon="nocodb" class="w-5 h-5" />
<span class="ml-1 text-[13px] font-weight-700"> NocoDB </span>
<span class="ml-1 text-[13px] font-weight-700"> {{ $t('objects.syncData.nocodb') }} </span>
<GeneralIcon icon="chevronRight" class="ml-auto text-lg" />
</NcMenuItem>
<!-- <NcMenuItem disabled>

View File

@@ -39,6 +39,8 @@ provide(ReadonlyInj, readOnly)
const isForm = inject(IsFormInj, ref(false))
const isUnderLTAR = inject(IsUnderLTARInj, ref(false))
const isGrid = inject(IsGridInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
@@ -298,7 +300,7 @@ const cellClassName = computed(() => {
<template>
<div
:class="cellClassName"
:class="[cellClassName, { 'nc-under-ltar': isUnderLTAR }]"
class="nc-cell w-full h-full relative"
@contextmenu="onContextmenu"
@keydown.enter.exact="navigate(NavigateDir.NEXT, $event)"

View File

@@ -54,7 +54,9 @@ provide(ColumnInj, column)
<LazyCellCheckbox v-if="isBoolean(column)" :model-value="cellValue" />
<LazyCellCurrency v-else-if="isCurrency(column)" :model-value="cellValue" />
<LazyCellDecimal v-else-if="isDecimal(column)" :model-value="cellValue" />
<LazyCellPercent v-else-if="isPercent(column)" :model-value="cellValue" />
<div v-else-if="isPercent(column)" class="h-[30px] min-h-[30px]">
<LazyCellPercentReadonly :model-value="cellValue" />
</div>
<LazyCellRating v-else-if="isRating(column)" :model-value="cellValue" />
<LazyCellDateReadonly v-else-if="isDate(column, '')" :model-value="cellValue" />
<LazyCellDateTimeReadonly v-else-if="isDateTime(column, '')" :model-value="cellValue" />

View File

@@ -54,10 +54,10 @@ const coverImageColumn: any = computed(() =>
: {},
)
const coverImageObjectFitClass = computed(() => {
const coverImageObjectFitStyle = computed(() => {
const fk_cover_image_object_fit = parseProp(galleryData.value?.meta)?.fk_cover_image_object_fit || CoverImageObjectFit.FIT
if (fk_cover_image_object_fit === CoverImageObjectFit.FIT) return '!object-contain'
if (fk_cover_image_object_fit === CoverImageObjectFit.COVER) return '!object-cover'
if (fk_cover_image_object_fit === CoverImageObjectFit.FIT) return 'contain'
if (fk_cover_image_object_fit === CoverImageObjectFit.COVER) return 'cover'
})
const hasEditPermission = computed(() => isUIAllowed('dataEdit'))
@@ -447,7 +447,7 @@ reloadViewDataHook?.on(async () => {
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.rowMeta.rowIndex}-${index}`"
class="h-52"
:class="[`${coverImageObjectFitClass}`]"
:object-fit="coverImageObjectFitStyle"
:srcs="getPossibleAttachmentSrc(attachment, 'card_cover')"
@click="expandFormClick($event, record)"
/>
@@ -467,7 +467,13 @@ reloadViewDataHook?.on(async () => {
(isRowEmpty(record, displayField) && isAllowToRenderRowEmptyField(displayField)),
}"
>
<template v-if="!isRowEmpty(record, displayField) || isAllowToRenderRowEmptyField(displayField)">
<template
v-if="
!isRowEmpty(record, displayField) ||
isAllowToRenderRowEmptyField(displayField) ||
isPercent(displayField)
"
>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(displayField)"
v-model="record.row[displayField.title]"

View File

@@ -102,11 +102,11 @@ const coverImageColumn: any = computed(() =>
: {},
)
const coverImageObjectFitClass = computed(() => {
const coverImageObjectFitStyle = computed(() => {
const fk_cover_image_object_fit = parseProp(kanbanMetaData.value?.meta)?.fk_cover_image_object_fit || CoverImageObjectFit.FIT
if (fk_cover_image_object_fit === CoverImageObjectFit.FIT) return '!object-contain'
if (fk_cover_image_object_fit === CoverImageObjectFit.COVER) return '!object-cover'
if (fk_cover_image_object_fit === CoverImageObjectFit.FIT) return 'contain'
if (fk_cover_image_object_fit === CoverImageObjectFit.COVER) return 'cover'
})
const isRequiredGroupingFieldColumn = computed(() => {
@@ -783,7 +783,11 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
@click="expandFormClick($event, record)"
@contextmenu="showContextMenu($event, record)"
>
<template v-if="kanbanMetaData?.fk_cover_image_col_id" #cover>
<!--
Check the coverImageColumn ID because kanbanMetaData?.fk_cover_image_col_id
could reference a non-existent column. This is a workaround to handle such scenarios properly.
-->
<template v-if="coverImageColumn?.id" #cover>
<template v-if="!reloadAttachments && attachments(record).length">
<a-carousel
:key="attachments(record).reduce((acc, curr) => acc + curr?.path, '')"
@@ -827,7 +831,7 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="attachment.path"
class="h-52"
:class="[`${coverImageObjectFitClass}`]"
:object-fit="coverImageObjectFitStyle"
:srcs="getPossibleAttachmentSrc(attachment, 'card_cover')"
/>
</template>
@@ -870,7 +874,7 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
:read-only="true"
/>
</template>
<template v-else> - </template>
<template v-else> -</template>
</h2>
<div
@@ -897,7 +901,7 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
</div>
<div
v-if="!isRowEmpty(record, col) || isAllowToRenderRowEmptyField(col)"
v-if="!isRowEmpty(record, col) || isAllowToRenderRowEmptyField(col) || isPercent(col)"
class="flex flex-row w-full text-gray-800 items-center justify-start min-h-7 py-1"
>
<LazySmartsheetVirtualCell
@@ -1236,9 +1240,11 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
.ant-layout-footer {
@apply !bg-white;
}
.ant-layout-content {
background-color: unset;
}
.ant-layout-header,
.ant-layout-footer {
@apply p-2 text-sm;
@@ -1271,6 +1277,7 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
.ant-carousel.gallery-carousel :deep(.slick-dots li) {
@apply !w-auto;
}
.ant-carousel.gallery-carousel :deep(.slick-prev) {
@apply left-0;
}
@@ -1351,6 +1358,7 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
.nc-readonly-rich-text-wrapper {
@apply !min-h-1;
}
.nc-rich-text {
@apply pl-0;
.tiptap.ProseMirror {
@@ -1359,15 +1367,19 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
}
}
}
&.nc-cell-checkbox {
@apply children:pl-0;
}
&.nc-cell-singleselect .nc-cell-field > div {
@apply flex items-center;
}
&.nc-cell-multiselect .nc-cell-field > div {
@apply h-5;
}
&.nc-cell-email,
&.nc-cell-phonenumber {
@apply flex items-center;
@@ -1386,6 +1398,7 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
.nc-links-wrapper {
@apply py-0 children:min-h-4;
}
&.nc-virtual-cell-linktoanotherrecord {
.chips-wrapper {
@apply min-h-4 !children:min-h-4;
@@ -1394,6 +1407,7 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
}
}
}
&.nc-virtual-cell-lookup {
.nc-lookup-cell {
&:has(.nc-attachment-wrapper) {
@@ -1407,14 +1421,17 @@ const draggableCardFilter = (event: Event, target: HTMLElement) => {
}
}
}
&:not(:has(.nc-attachment-wrapper)) {
@apply !h-5.5;
}
.nc-cell-lookup-scroll {
@apply py-0 h-auto;
}
}
}
&.nc-virtual-cell-formula {
.nc-cell-field {
@apply py-0;

View File

@@ -1,15 +1,41 @@
<script lang="ts" setup>
const props = defineProps<{
active?: boolean
}>()
const { active } = toRefs(props)
const el = ref<HTMLElement>()
const cellClickHook = createEventHook()
const cellEventHook = createEventHook()
provide(CellClickHookInj, cellClickHook)
provide(CellEventHookInj, cellEventHook)
provide(CurrentCellInj, el)
const handleClick = (event: MouseEvent) => {
cellClickHook.trigger(event)
cellEventHook.trigger(event)
}
useActiveKeydownListener(
active,
(event) => {
cellEventHook.trigger(event)
},
{
isGridCell: true,
immediate: true,
},
)
</script>
<template>
<td ref="el" class="select-none" @click="cellClickHook.trigger($event)">
<td ref="el" class="select-none" @click="handleClick">
<slot />
</td>
</template>

View File

@@ -9,10 +9,6 @@ const props = defineProps<{
const emit = defineEmits(['expandRecord', 'newRecord'])
interface Attachment {
url: string
}
const INFINITY_SCROLL_THRESHOLD = 100
const { isUIAllowed } = useRoles()
@@ -25,10 +21,6 @@ const { height } = useWindowSize()
const meta = inject(MetaInj, ref())
const { fields } = useViewColumnsOrThrow()
const { getPossibleAttachmentSrc } = useAttachment()
const { t } = useI18n()
const {
@@ -54,23 +46,6 @@ const {
const sideBarListRef = ref<VNodeRef | null>(null)
const coverImageColumns: any = computed(() => {
if (!fields.value || !meta.value?.columns) return
return meta.value.columns.find((c) => c.uidt === UITypes.Attachment && fields.value?.find((f) => f.fk_column_id === c.id).show)
})
const attachments = (record: any): Attachment[] => {
const col = coverImageColumns.value
try {
if (col?.title && record.row[col.title]) {
return typeof record.row[col.title] === 'string' ? JSON.parse(record.row[col.title]) : record.row[col.title]
}
return []
} catch (e) {
return []
}
}
const pushToArray = (arr: Array<Row>, record: Row, range) => {
arr.push({
...record,
@@ -552,50 +527,6 @@ onClickOutside(searchRef, toggleSearch)
@dragstart="dragStart($event, record)"
@dragover.prevent
>
<template v-if="coverImageColumns" #image>
<a-carousel
v-if="attachments(record).length"
class="gallery-carousel rounded-md !border-1 !border-gray-200 w-10 h-10"
arrows
>
<template #customPaging>
<a>
<div class="pt-[12px]">
<div></div>
</div>
</a>
</template>
<template #prevArrow>
<div class="z-10 arrow">
<MdiChevronLeft
class="text-gray-700 w-6 h-6 absolute left-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition"
/>
</div>
</template>
<template #nextArrow>
<div class="z-10 arrow">
<MdiChevronRight
class="text-gray-700 w-6 h-6 absolute right-1.5 bottom-[-90px] !opacity-0 !group-hover:opacity-100 !bg-white border-1 border-gray-200 rounded-md transition"
/>
</div>
</template>
<template v-for="(attachment, index) in attachments(record)">
<LazyCellAttachmentPreviewImage
v-if="isImage(attachment.title, attachment.mimetype ?? attachment.type)"
:key="`carousel-${record.row.id}-${index}`"
class="h-10 !w-10 !object-contain"
:srcs="getPossibleAttachmentSrc(attachment, 'tiny')"
/>
</template>
</a-carousel>
<div v-else class="h-10 w-10 !flex flex-row !border-1 rounded-md !border-gray-200 items-center justify-center">
<img class="object-contain w-[40px] h-[40px]" src="~assets/icons/FileIconImageBox.png" />
</div>
</template>
<template v-if="!isRowEmpty(record, displayField)">
<LazySmartsheetPlainCell v-model="record.row[displayField!.title!]" :column="displayField" />
</template>

View File

@@ -48,7 +48,7 @@ const props = withDefaults(defineProps<Props>(), {
record.
</template>
</NcTooltip>
<span v-if="showDate" class="text-xs font-medium leading-4 text-gray-600"
<span v-if="showDate" class="text-xs font-medium truncate leading-4 text-gray-600"
>{{ fromDate }} {{ toDate ? ` - ${toDate}` : '' }}</span
>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { UITypes } from 'nocodb-sdk'
const props = defineProps<{
value: any
isVisibleDefaultValueInput: boolean
@@ -30,6 +31,8 @@ const cdfValue = ref<string | null>(null)
const editEnabled = ref(false)
const defaultValueWrapperRef = ref<HTMLDivElement>()
const updateCdfValue = (cdf: string | null) => {
vModel.value = { ...vModel.value, cdf }
cdfValue.value = cdf
@@ -62,6 +65,26 @@ const isCurrentDate = computed(() => {
})
const { isSystem } = useColumnCreateStoreOrThrow()
const validationError = computed(() => {
return getColumnValidationError(vModel.value)
})
const handleShowInput = () => {
isVisibleDefaultValueInput.value = true
// In playwright testing we first enable this default input and then start filling all fields
// So it's imp to not to focus input
if (ncIsPlaywright()) return
nextTick(() => {
ncDelay(300).then(() => {
if (defaultValueWrapperRef.value) {
focusInputEl('.nc-cell', defaultValueWrapperRef.value)
}
})
})
}
</script>
<template>
@@ -72,7 +95,7 @@ const { isSystem } = useColumnCreateStoreOrThrow()
class="!text-gray-700"
data-testid="nc-show-default-value-btn"
:disabled="isSystem"
@click.stop="isVisibleDefaultValueInput = true"
@click.stop="handleShowInput"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" class="flex-none h-4 w-4" />
@@ -87,12 +110,12 @@ const { isSystem } = useColumnCreateStoreOrThrow()
</div>
<div class="flex flex-row gap-2 relative">
<div
class="nc-default-value-wrapper border-1 flex items-center w-full px-3 border-gray-300 rounded-lg sm:min-h-[32px] xs:min-h-13 flex items-center focus-within:(border-brand-500 shadow-selected ring-0) transition-all duration-0.3s"
class="nc-default-value-wrapper border-1 flex items-center w-full px-3 border-gray-300 rounded-lg sm:min-h-[32px] xs:min-h-13 focus-within:(border-brand-500 shadow-selected ring-0) transition-all duration-0.3s"
:class="{
'bg-white': isAiModeFieldModal,
}"
>
<div class="relative flex-grow max-w-full">
<div ref="defaultValueWrapperRef" class="relative flex-grow max-w-full">
<div
v-if="isCurrentDate"
class="absolute pointer-events-none h-full w-full bg-white z-2 top-0 left-0 rounded-full items-center flex bg-white"
@@ -111,17 +134,20 @@ const { isSystem } = useColumnCreateStoreOrThrow()
@click="editEnabled = true"
/>
</div>
<component
:is="iconMap.close"
v-if="
![UITypes.Year, UITypes.Date, UITypes.Time, UITypes.DateTime, UITypes.SingleSelect, UITypes.MultiSelect].includes(
vModel.uidt,
) || isCurrentDate
"
class="w-4 h-4 cursor-pointer rounded-full z-3 !text-black-500 text-gray-500 cursor-pointer hover:bg-gray-50 default-value-clear"
@click.stop="updateCdfValue(null)"
/>
<NcTooltip :title="$t('general.clear')" class="flex">
<component
:is="iconMap.close"
v-if="
![UITypes.Year, UITypes.Date, UITypes.Time, UITypes.DateTime, UITypes.SingleSelect, UITypes.MultiSelect].includes(
vModel.uidt,
) || isCurrentDate
"
class="w-4 h-4 cursor-pointer rounded-full z-3 !text-black-500 text-gray-500 hover:bg-gray-50 default-value-clear"
@click.stop="updateCdfValue(null)"
/>
</NcTooltip>
</div>
</div>
<div v-if="validationError" class="text-nc-content-red-medium text-small leading-[18px] mt-1">{{ $t(validationError) }}</div>
</div>
</template>

View File

@@ -144,8 +144,6 @@ const isVisibleDefaultValueInput = computed({
},
})
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
const onlyNameUpdateOnEditColumns = [
UITypes.LinkToAnotherRecord,
UITypes.Lookup,

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted } from '@vue/runtime-core'
import type { ColumnType, LinkToAnotherRecordType, LookupType, TableType } from 'nocodb-sdk'
import { UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
const props = defineProps<{
value: any
@@ -38,7 +38,18 @@ const refTables = computed(() => {
}
const _refTables = meta.value.columns
.filter((column) => isLinksOrLTAR(column) && !column.system && column.source_id === meta.value?.source_id)
.filter(
(column) =>
isLinksOrLTAR(column) &&
// exclude system columns
(!column.system ||
// include system columns if it's self-referencing, mm, oo and bt are self-referencing
// hm is only used for LTAR with junction table
[RelationTypes.MANY_TO_MANY, RelationTypes.ONE_TO_ONE, RelationTypes.BELONGS_TO].includes(
(column.colOptions as LinkToAnotherRecordType).type as RelationTypes,
)) &&
column.source_id === meta.value?.source_id,
)
.map((column) => ({
col: column.colOptions,
column,

View File

@@ -3,7 +3,7 @@ const props = defineProps<{
value: any
isVisibleDefaultValueInput: boolean
}>()
const emits = defineEmits(['update:value'])
const emits = defineEmits(['update:value', 'update:isVisibleDefaultValueInput'])
provide(EditColumnInj, ref(true))
@@ -13,6 +13,8 @@ const isVisibleDefaultValueInput = useVModel(props, 'isVisibleDefaultValueInput'
const { isAiModeFieldModal } = usePredictFields()
const defaultValueWrapperRef = ref<HTMLDivElement>()
const cdfValue = computed({
get: () => vModel.value.cdf,
set: (value) => {
@@ -23,6 +25,22 @@ const cdfValue = computed({
}
},
})
const handleShowInput = () => {
isVisibleDefaultValueInput.value = true
// In playwright testing we first enable this default input and then start filling all fields
// So it's imp to not to focus input
if (ncIsPlaywright()) return
nextTick(() => {
ncDelay(300).then(() => {
if (defaultValueWrapperRef.value) {
focusInputEl('.nc-cell', defaultValueWrapperRef.value)
}
})
})
}
</script>
<template>
@@ -32,7 +50,7 @@ const cdfValue = computed({
type="text"
class="text-gray-700"
data-testid="nc-show-default-value-btn"
@click.stop="isVisibleDefaultValueInput = true"
@click.stop="handleShowInput"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" class="flex-none h-4 w-4" />
@@ -47,6 +65,7 @@ const cdfValue = computed({
</div>
<div class="flex flex-row gap-2">
<div
ref="defaultValueWrapperRef"
class="nc-default-value-wrapper nc-rich-long-text-default-value border-1 relative pt-7 flex items-center w-full px-0 border-gray-300 rounded-md max-h-70 pb-1 focus-within:(border-brand-500 shadow-selected) transition-all duration-0.3s"
:class="{
'bg-white': isAiModeFieldModal,

View File

@@ -54,7 +54,13 @@ const refTables = computed(() => {
![RelationTypes.BELONGS_TO, RelationTypes.ONE_TO_ONE].includes(
(c.colOptions as LinkToAnotherRecordType).type as RelationTypes,
) &&
!c.system &&
// exclude system columns
(!c.system ||
// include system columns if it's self-referencing, mm, oo and bt are self-referencing
// hm is only used for LTAR with junction table
[RelationTypes.MANY_TO_MANY, RelationTypes.ONE_TO_ONE, RelationTypes.BELONGS_TO].includes(
(c.colOptions as LinkToAnotherRecordType).type as RelationTypes,
)) &&
c.source_id === meta.value?.source_id,
)
.map((c: ColumnType) => ({

View File

@@ -18,8 +18,10 @@ const shouldClose = (isVisible: boolean, i: number) => {
:use-meta-fields="state.useMetaFields"
:maintain-default-view-order="state.maintainDefaultViewOrder"
:view="state.view"
:skip-reload="state.skipReload"
@update:model-value="shouldClose($event, i)"
@cancel="close(i)"
@created-record="state?.createdRecord?.($event)"
/>
</template>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ColumnType, TableType, ViewType } from 'nocodb-sdk'
import { ViewTypes, isSystemColumn } from 'nocodb-sdk'
import { ViewTypes, isReadOnlyColumn, isSystemColumn } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import NcModal from '../../nc/Modal.vue'
@@ -87,24 +87,95 @@ const { isExpandedFormCommentMode } = storeToRefs(useConfigStore())
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, undefined)
const isKanban = inject(IsKanbanInj, ref(false))
provide(MetaInj, meta)
// override cell event hook to avoid unexpected behavior at form fields
// issue happens when opening expanded form from cell (LTAR/Links)
provide(CanvasSelectCellInj, undefined)
const isLoading = ref(true)
const isSaving = ref(false)
const expandedFormStore = useProvideExpandedFormStore(meta, row)
const {
commentsDrawer,
changedColumns,
deleteRowById,
displayValue,
state: rowState,
isNew,
loadRow: _loadRow,
primaryKey,
row: _row,
comments,
save: _save,
loadComments,
loadAudits,
clearColumns,
} = expandedFormStore
const loadingEmit = (event: 'update:modelValue' | 'cancel' | 'next' | 'prev' | 'createdRecord') => {
emits(event)
isLoading.value = true
}
const fields = computedInject(FieldsInj, (_fields) => {
/**
* Injects the fields from the parent component if available.
* Uses a ref to ensure reactivity.
*/
const fieldsFromParent = inject<Ref<ColumnType[] | null>>(FieldsInj, ref(null))
/**
* Computes the list of fields to be used based on the given conditions.
*
* - Prefers `props.useMetaFields` over `fieldsFromParent` if enabled.
* - Filters out system columns and readonly fields for new records.
* - Maintains default view order if `maintainDefaultViewOrder` is enabled.
*
* @returns {ColumnType[]} The computed list of fields.
*/
const fields = computed(() => {
// Give preference to props.useMetaFields instead of fieldsFromParent
if (props.useMetaFields) {
if (maintainDefaultViewOrder.value) {
return (meta.value.columns ?? [])
.filter((col) => !isSystemColumn(col) && !!col.meta?.defaultViewColVisibility)
.filter(
(col) =>
!isSystemColumn(col) &&
!!col.meta?.defaultViewColVisibility &&
// if new record, then hide readonly fields
(!isNew.value || !isReadOnlyColumn(col)),
)
.sort((a, b) => {
return (a.meta?.defaultViewColOrder ?? Infinity) - (b.meta?.defaultViewColOrder ?? Infinity)
})
}
return (meta.value.columns ?? []).filter((col) => !isSystemColumn(col) && !!col.meta?.defaultViewColVisibility)
return (meta.value.columns ?? []).filter(
(col) =>
// if new record, then hide readonly fields
(!isNew.value || !isReadOnlyColumn(col)) &&
// exclude system columns
!isSystemColumn(col) &&
// exclude hidden columns
!!col.meta?.defaultViewColVisibility,
)
}
return _fields?.value ?? []
// If `props.useMetaFields` is not enabled, use fields from the parent component
if (fieldsFromParent.value) {
if (isNew.value) {
return fieldsFromParent.value.filter((col) => !isReadOnlyColumn(col))
}
return fieldsFromParent.value
}
return []
})
const tableTitle = computed(() => meta.value?.title)
@@ -135,7 +206,9 @@ const hiddenFields = computed(() => {
(col) =>
!isSystemColumn(col) &&
!fields.value?.includes(col) &&
(isLocalMode.value && col?.id && fieldsMap.value[col.id] ? fieldsMap.value[col.id]?.initialShow : true),
(isLocalMode.value && col?.id && fieldsMap.value[col.id] ? fieldsMap.value[col.id]?.initialShow : true) &&
// exclude readonly fields from hidden fields if new record creation
(!isNew.value || !isReadOnlyColumn(col)),
)
if (props.useMetaFields) {
return maintainDefaultViewOrder.value
@@ -152,35 +225,22 @@ const hiddenFields = computed(() => {
}
})
const isKanban = inject(IsKanbanInj, ref(false))
reloadViewDataTrigger.on(async (params) => {
const isSameRecordUpdated =
params?.relatedTableMetaId && params?.rowId && params?.relatedTableMetaId === meta.value?.id && params?.rowId === rowId.value
provide(MetaInj, meta)
// If relatedTableMetaId & rowId is present that means some nested record is updated
const isLoading = ref(true)
const isSaving = ref(false)
const expandedFormStore = useProvideExpandedFormStore(meta, row)
const {
commentsDrawer,
changedColumns,
deleteRowById,
displayValue,
state: rowState,
isNew,
loadRow: _loadRow,
primaryKey,
row: _row,
comments,
save: _save,
loadComments,
loadAudits,
clearColumns,
} = expandedFormStore
reloadViewDataTrigger.on(async () => {
await _loadRow(rowId.value, false, true)
// If same nested record udpated then udpate whole row
if (isSameRecordUpdated) {
await _loadRow(rowId.value)
} else if (params?.relatedTableMetaId && params?.rowId) {
// If it is not same record updated but it has relatedTableMetaId & rowId then update only virtual columns
await _loadRow(rowId.value, true)
} else {
// Else update only new/duplicated/renamed columns
await _loadRow(rowId.value, false, true)
}
})
const duplicatingRowInProgress = ref(false)
@@ -621,6 +681,12 @@ const modalProps = computed(() => {
return {}
})
// check if the row is new and has some changes on LTAR/Links
// this is to enable save if there are changes on LTAR/Links
const isLTARChanged = computed(() => {
return isNew.value && row.value?.rowMeta?.ltarState && Object.keys(row.value?.rowMeta?.ltarState).length > 0
})
watch(
() => comments.value.length,
(commentCount) => {
@@ -668,10 +734,10 @@ export default {
<div
class="flex gap-2 min-h-7 flex-shrink-0 w-full items-center nc-expanded-form-header p-4 xs:(px-2 py-0 min-h-[48px]) border-b-1 border-gray-200"
>
<div class="flex gap-2">
<div class="flex gap-2 min-w-0 min-h-8">
<div class="flex gap-2">
<NcTooltip v-if="props.showNextPrevIcons">
<template #title> {{ renderAltOrOptlKey() }} + </template>
<NcTooltip v-if="props.showNextPrevIcons" class="flex items-center">
<template #title> {{ renderAltOrOptlKey() }} + </template>
<NcButton
:disabled="isFirstRow || isLoading"
class="nc-prev-arrow !w-7 !h-7 !text-gray-500 !disabled:text-gray-300"
@@ -682,8 +748,8 @@ export default {
<GeneralIcon icon="chevronDown" class="transform rotate-180" />
</NcButton>
</NcTooltip>
<NcTooltip v-if="props.showNextPrevIcons">
<template #title> {{ renderAltOrOptlKey() }} + </template>
<NcTooltip v-if="props.showNextPrevIcons" class="flex items-center">
<template #title> {{ renderAltOrOptlKey() }} + </template>
<NcButton
:disabled="islastRow || isLoading"
class="nc-next-arrow !w-7 !h-7 !text-gray-500 !disabled:text-gray-300"
@@ -698,7 +764,7 @@ export default {
<div v-if="isLoading" class="flex items-center">
<a-skeleton-input active class="!h-6 !sm:mr-14 !w-52 !rounded-md !overflow-hidden" size="small" />
</div>
<div v-else class="flex-1 flex items-center gap-2 xs:(flex-row-reverse justify-end)">
<div v-else class="flex-1 flex items-center gap-2 xs:(flex-row-reverse justify-end) min-w-0">
<div v-if="!props.showNextPrevIcons" class="hidden md:flex items-center rounded-lg bg-gray-100 px-2 py-1 gap-2">
<GeneralIcon icon="table" class="text-gray-700" />
<span class="nc-expanded-form-table-name">
@@ -715,8 +781,14 @@ export default {
v-else-if="displayValue && !row?.rowMeta?.new"
class="flex items-center font-bold text-gray-800 text-2xl overflow-hidden"
>
<span class="truncate w-[120px] md:w-[300px]">
<LazySmartsheetPlainCell v-model="displayValue" :column="displayField" />
<span class="min-w-[120px] md:min-w-[300px]">
<NcTooltip class="truncate" show-on-truncate-only>
<template #title>
{{ displayValue }}
</template>
<LazySmartsheetPlainCell v-model="displayValue" :column="displayField" />
</NcTooltip>
</span>
</div>
</div>
@@ -751,10 +823,10 @@ export default {
</div>
<div class="flex gap-2">
<NcTooltip v-if="!isMobileMode && isUIAllowed('dataEdit') && !isSqlView">
<template #title> {{ renderAltOrOptlKey() }} + S </template>
<template #title> {{ renderAltOrOptlKey() }} + S</template>
<NcButton
v-e="['c:row-expand:save']"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist"
:disabled="changedColumns.size === 0 && !isUnsavedFormExist && !isLTARChanged"
:loading="isSaving"
class="nc-expand-form-save-btn !xs:(text-base) !h-7 !px-2"
data-testid="nc-expanded-form-save"
@@ -952,9 +1024,11 @@ export default {
.nc-expanded-cell-header > :nth-child(2) {
@apply !text-sm xs:!text-small;
}
.nc-expanded-cell-header > :first-child {
@apply !text-md pl-2 xs:(pl-0 -ml-0.5);
}
.nc-expanded-cell-header:not(.nc-cell-expanded-form-header) > :first-child {
@apply pl-0;
}

View File

@@ -63,6 +63,7 @@ const props = defineProps<{
chunkStates: Array<'loading' | 'loaded' | undefined>
isBulkOperationInProgress: boolean
selectedAllRecords?: boolean
getRows: (start: number, end: number) => Promise<Row[]>
}>()
const emits = defineEmits(['bulkUpdateDlg', 'update:selectedAllRecords'])
@@ -85,6 +86,7 @@ const {
updateRecordOrder,
applySorting,
bulkDeleteAll,
getRows,
} = props
// Injections
@@ -859,11 +861,16 @@ const {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey
if (e.key === ' ') {
if (e.shiftKey) return true
const isRichModalOpen = isExpandedCellInputExist()
if (isCellActive.value && !editEnabled.value && hasEditPermission.value && activeCell.row !== null && !isRichModalOpen) {
if (!editEnabled.value && isCellActive.value && activeCell.row !== null && !isRichModalOpen) {
e.preventDefault()
const row = cachedRows.value.get(activeCell.row)
if (!row) return
expandForm?.(row)
return true
}
@@ -1028,6 +1035,7 @@ const {
undefined,
fetchChunk,
onActiveCellChanged,
getRows,
)
function scrollToRow(row?: number) {
@@ -1138,12 +1146,12 @@ const isSelectedOnlyScript = computed(() => {
const { runScript } = useScriptExecutor()
const bulkExecuteScript = () => {
const bulkExecuteScript = async () => {
if (!isSelectedOnlyScript.value.enabled || !meta?.value?.id || !meta.value.columns) return
const field = fields.value[selectedRange.start.col]
const rows = Array.from(cachedRows.value.values()).slice(selectedRange.start.row, selectedRange.end.row + 1)
const rows = await getRows(selectedRange.start.row, selectedRange.end.row + 1)
for (const row of rows) {
const pk = extractPkFromRow(row.row, meta.value.columns)
@@ -1163,7 +1171,7 @@ const generateAIBulk = async () => {
if (!field.id) return
const rows = Array.from(cachedRows.value.values()).slice(selectedRange.start.row, selectedRange.end.row + 1)
const rows = await getRows(selectedRange.start.row, selectedRange.end.row + 1)
if (!rows || rows.length === 0) return
@@ -1287,7 +1295,7 @@ async function clearSelectedRangeOfCells() {
const cols = fields.value.slice(startCol, endCol + 1)
// Get rows in the selected range
const rows = Array.from(cachedRows.value.values()).slice(start.row, end.row + 1)
const rows = await getRows(start.row, end.row)
const props = []
let isInfoShown = false
@@ -2604,6 +2612,10 @@ const cellAlignClass = computed(() => {
<SmartsheetTableDataCell
v-if="fields[0]"
:key="fields[0].id"
:active="
(activeCell.row === row.rowMeta.rowIndex && activeCell.col === 0) ||
(selectedRange._start?.row === row.rowMeta.rowIndex && selectedRange._start?.col === 0)
"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'active': selectRangeMap[`${row.rowMeta.rowIndex}-0`],
@@ -2700,6 +2712,10 @@ const cellAlignClass = computed(() => {
<SmartsheetTableDataCell
v-for="{ field: columnObj, index: colIndex } of visibleFields"
:key="`cell-${colIndex}-${row.rowMeta.rowIndex}`"
:active="
(activeCell.row === row.rowMeta.rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === row.rowMeta.rowIndex && selectedRange._start?.col === colIndex)
"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'active': selectRangeMap[`${row.rowMeta.rowIndex}-${colIndex}`],

View File

@@ -112,7 +112,10 @@ const getAddnlMargin = (depth: number, ignoreCondition = false) => {
>
<div
v-if="displayFieldComputed.field && displayFieldComputed.column?.id"
class="flex items-center overflow-x-hidden hover:bg-gray-100 cursor-pointer text-gray-500 justify-end transition-all transition-linear px-3 py-2"
class="flex items-center overflow-x-hidden hover:bg-gray-100 text-gray-500 justify-end transition-all transition-linear px-3 py-2"
:class="{
'cursor-pointer': !isLocked,
}"
:style="{
'min-width': displayFieldComputed?.width,
'max-width': displayFieldComputed?.width,
@@ -158,7 +161,7 @@ const getAddnlMargin = (depth: number, ignoreCondition = false) => {
<div
v-if="!displayFieldComputed.field?.aggregation || displayFieldComputed.field?.aggregation === 'none'"
:class="{
'group-hover:opacity-100': ![UITypes.SpecificDBType, UITypes.ForeignKey, UITypes.Button].includes(displayFieldComputed.column?.uidt!)
'group-hover:opacity-100': !isLocked,
}"
class="text-gray-500 opacity-0 transition"
>
@@ -240,7 +243,10 @@ const getAddnlMargin = (depth: number, ignoreCondition = false) => {
overlay-class-name="max-h-96 relative scroll-container nc-scrollbar-md overflow-auto"
>
<div
class="flex items-center overflow-hidden justify-end group hover:bg-gray-100 cursor-pointer text-gray-500 transition-all transition-linear px-3 py-2"
class="flex items-center overflow-hidden justify-end group hover:bg-gray-100 text-gray-500 transition-all transition-linear px-3 py-2"
:class="{
'cursor-pointer': !isLocked,
}"
:style="{
'min-width': width,
'max-width': width,
@@ -251,8 +257,8 @@ const getAddnlMargin = (depth: number, ignoreCondition = false) => {
<div
v-if="field?.aggregation === 'none' || field?.aggregation === null"
:class="{
'group-hover:opacity-100': ![UITypes.SpecificDBType, UITypes.ForeignKey, UITypes.Button].includes(column?.uidt!)
}"
'group-hover:opacity-100': !isLocked,
}"
class="text-gray-500 opacity-0 transition"
>
<GeneralIcon class="text-gray-500" icon="arrowDown" />

View File

@@ -605,12 +605,15 @@ const {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
const altOrOptionKey = e.altKey
if (e.key === ' ') {
if (e.key === ' ' && !e.shiftKey) {
const isRichModalOpen = isExpandedCellInputExist()
if (isCellActive.value && !editEnabled.value && hasEditPermission.value && activeCell.row !== null && !isRichModalOpen) {
if (!editEnabled.value && isCellActive.value && activeCell.row !== null && !isRichModalOpen) {
e.preventDefault()
const row = dataRef.value[activeCell.row]
if (!row) return true
expandForm?.(row)
return true
}
@@ -2227,6 +2230,10 @@ onKeyStroke('ArrowDown', onDown)
<SmartsheetTableDataCell
v-if="fields[0]"
:key="fields[0].id"
:active="
(activeCell.row === rowIndex && activeCell.col === 0) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === 0)
"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'active': selectRangeMap[`${rowIndex}-0`],
@@ -2299,6 +2306,10 @@ onKeyStroke('ArrowDown', onDown)
<SmartsheetTableDataCell
v-for="{ field: columnObj, index: colIndex } of visibleFields"
:key="`cell-${colIndex}-${rowIndex}`"
:active="
(activeCell.row === rowIndex && activeCell.col === colIndex) ||
(selectedRange._start?.row === rowIndex && selectedRange._start?.col === colIndex)
"
class="cell relative nc-grid-cell cursor-pointer"
:class="{
'active': selectRangeMap[`${rowIndex}-${colIndex}`],

View File

@@ -302,6 +302,35 @@ export const AttachmentCellRenderer: CellRenderer = {
for now, the tooltip does not make sense */
return
}
if (attachments.length) {
const buttonY = y + 5
const maximizeBox = {
x: x + width - 30,
y: buttonY,
width: 18,
height: 18,
}
const attachBox = {
x: x + 11,
y: buttonY,
width: 18,
height: 18,
}
if (
tryShowTooltip({
rect: maximizeBox,
text: `${getI18n().global.t('activity.viewAttachment')} '${getI18n().global.t('tooltip.shiftSpace')}'`,
mousePosition,
})
)
return
if (tryShowTooltip({ rect: attachBox, text: getI18n().global.t('activity.addFiles'), mousePosition })) return
}
const rowHeight = pxToRowHeight[height] ?? 1
const { getPossibleAttachmentSrc } = useAttachment()
@@ -365,35 +394,14 @@ export const AttachmentCellRenderer: CellRenderer = {
})
const hoveredPreview = imageBoxes.find((box) => isBoxHovered(box, mousePosition))
if (tryShowTooltip({ rect: hoveredPreview, text: hoveredPreview?.title ?? '', mousePosition })) {
return
}
if (!attachments.length) return
const buttonY = y + 5
const maximizeBox = {
x: x + width - 30,
y: buttonY,
width: 18,
height: 18,
}
const attachBox = {
x: x + 11,
y: buttonY,
width: 18,
height: 18,
}
tryShowTooltip({ rect: maximizeBox, text: getI18n().global.t('activity.viewAttachment'), mousePosition })
tryShowTooltip({ rect: attachBox, text: getI18n().global.t('activity.addFiles'), mousePosition })
tryShowTooltip({ rect: hoveredPreview, text: hoveredPreview?.title ?? '', mousePosition })
},
async handleKeyDown({ row, column, e, makeCellEditable }) {
if (e.key === 'Enter') {
if (e.key === 'Enter' || isExpandCellKey(e)) {
makeCellEditable(row.rowMeta.rowIndex!, column)
return true
}
return false
},

View File

@@ -1,6 +1,6 @@
import dayjs from 'dayjs'
import { defaultOffscreen2DContext, isBoxHovered, renderSingleLineText, renderTagLabel, truncateText } from '../utils/canvas'
import { parseFlexibleDate } from '~/utils/datetimeUtils'
const defaultDateFormat = 'YYYY-MM-DD'
export const DateCellRenderer: CellRenderer = {
@@ -23,6 +23,11 @@ export const DateCellRenderer: CellRenderer = {
const date = dayjs(/^\d+$/.test(value) ? +value : value, defaultDateFormat)
if (date.isValid()) {
formattedDate = date.format(dateFormat)
} else {
const parsedDate = parseFlexibleDate(value)
if (parsedDate) {
formattedDate = parsedDate.format(dateFormat)
}
}
}

View File

@@ -53,7 +53,8 @@ export const DecimalCellRenderer: CellRenderer = {
if (column.readonly || column.columnObj?.readonly) return
const columnObj = column.columnObj
if (/^[0-9]$/.test(e.key) && columnObj.title) {
row.row[columnObj.title] = ''
// default null as to not raise error
row.row[columnObj.title] = null
makeCellEditable(row, column)
return true
}

View File

@@ -1,6 +1,13 @@
import { type ColumnType, FormulaDataTypes, UITypes, handleTZ } from 'nocodb-sdk'
import { defaultOffscreen2DContext, isBoxHovered, renderFormulaURL, renderSingleLineText } from '../utils/canvas'
import {
defaultOffscreen2DContext,
isBoxHovered,
renderFormulaURL,
renderIconButton,
renderSingleLineText,
} from '../utils/canvas'
import { showFieldEditWarning } from '../utils/cell'
import { getI18n } from '../../../../../plugins/a.i18n'
import { CheckboxCellRenderer } from './Checkbox'
import { CurrencyRenderer } from './Currency'
import { DateCellRenderer } from './Date'
@@ -14,9 +21,8 @@ import { SingleLineTextCellRenderer } from './SingleLineText'
import { TimeCellRenderer } from './Time'
import { UrlCellRenderer } from './Url'
import { FloatCellRenderer } from './Number'
import { LongTextCellRenderer } from './LongText'
function getDisplayValueCellRenderer(column: ColumnType, showAsLongText: boolean = false) {
function getDisplayValueCellRenderer(column: ColumnType) {
const colMeta = parseProp(column.meta)
const modifiedColumn = {
uidt: colMeta?.display_type,
@@ -33,19 +39,9 @@ function getDisplayValueCellRenderer(column: ColumnType, showAsLongText: boolean
else if (isEmail(modifiedColumn)) return EmailCellRenderer
else if (isURL(modifiedColumn)) return UrlCellRenderer
else if (isPhoneNumber(modifiedColumn)) return PhoneNumberCellRenderer
else if (showAsLongText) return LongTextCellRenderer
else return SingleLineTextCellRenderer
}
function shouldShowAsLongText({ column, width, value }: { column: ColumnType; width: number; value: any }) {
defaultOffscreen2DContext.font = '500 13px Manrope'
return (
(!column.colOptions?.parsed_tree?.dataType || column.colOptions?.parsed_tree?.dataType === FormulaDataTypes.STRING) &&
width - 24 <= defaultOffscreen2DContext.measureText(value?.toString() ?? '').width
)
}
export const FormulaCellRenderer: CellRenderer = {
render: (ctx, props) => {
const {
@@ -61,9 +57,12 @@ export const FormulaCellRenderer: CellRenderer = {
textColor = '#4a5268',
mousePosition,
setCursor,
isUnderLookup,
spriteLoader,
} = props
const colMeta = parseProp(column.meta)
const isHovered = isBoxHovered({ x, y, width, height }, mousePosition)
if (parseProp(column.colOptions)?.error) {
renderSingleLineText(ctx, {
text: 'ERR!',
@@ -73,10 +72,9 @@ export const FormulaCellRenderer: CellRenderer = {
return
}
const showAsLongText = !isUnderLookup && shouldShowAsLongText({ width, value, column })
if (colMeta?.display_type || showAsLongText) {
getDisplayValueCellRenderer(column, showAsLongText).render(ctx, {
// If Custom Formatting is applied to the column, render the cell using the display type cell renderer
if (colMeta?.display_type) {
getDisplayValueCellRenderer(column).render(ctx, {
...props,
column: {
...column,
@@ -86,40 +84,71 @@ export const FormulaCellRenderer: CellRenderer = {
readonly: true,
formula: true,
})
} else {
const result = isPg(column.source_id) ? renderValue(handleTZ(value)) : renderValue(value)
return
}
if (column?.colOptions?.parsed_tree?.dataType === FormulaDataTypes.NUMERIC) {
FloatCellRenderer.render(ctx, {
...props,
value: result,
formula: true,
})
return
}
const result = isPg(column.source_id) ? renderValue(handleTZ(value)) : renderValue(value)
// If the resultant type is Numeric, render as a Numeric Field
if (column?.colOptions?.parsed_tree?.dataType === FormulaDataTypes.NUMERIC) {
FloatCellRenderer.render(ctx, {
...props,
value: result,
formula: true,
})
return
}
// Render as String
if (column?.colOptions?.parsed_tree?.dataType === FormulaDataTypes.STRING) {
// This returns a false, if the field does not contain any URL
const urls = replaceUrlsWithLink(result)
const maxWidth = width - padding * 2
// If the field uses URL formula render it as a clickable link
if (typeof urls === 'string') {
const texts = getFormulaTextSegments(urls)
ctx.font = `${pv ? 600 : 500} 13px Manrope`
ctx.fillStyle = pv ? '#3366FF' : textColor
const boxes = renderFormulaURL(ctx, {
texts,
htmlText: urls,
height,
maxWidth,
x: x + padding,
y: y + 3,
lineHeight: 16,
underlineOffset: y < 36 ? 0 : 3,
})
const hoveredBox = boxes.find((box) => isBoxHovered(box, mousePosition))
if (hoveredBox) {
setCursor('pointer')
}
return
} else {
// If it does not contaisn urls, render as a SingleLineText
SingleLineTextCellRenderer.render(ctx, {
...props,
value: result,
formula: true,
})
}
if (isHovered) {
renderIconButton(ctx, {
buttonX: x + width - 28,
buttonY: y + 7,
buttonSize: 20,
borderRadius: 6,
iconData: {
size: 13,
xOffset: (20 - 13) / 2,
yOffset: (20 - 13) / 2,
},
mousePosition,
spriteLoader,
icon: 'maximize',
background: 'white',
setCursor,
})
}
} else {
// If not of type string render as a SingleLineText
SingleLineTextCellRenderer.render(ctx, {
...props,
value: result,
@@ -128,29 +157,28 @@ export const FormulaCellRenderer: CellRenderer = {
}
},
handleClick: async (props) => {
const { column, getCellPosition, value } = props
const { column, getCellPosition, value, openDetachedLongText, selected, mousePosition } = props
const colObj = column.columnObj
const colMeta = parseProp(colObj.meta)
const error = parseProp(colObj.colOptions)?.error ?? ''
const { x, y, width, height } = getCellPosition(column, props.row.rowMeta.rowIndex!)
const baseStore = useBase()
const { isPg } = baseStore
// isUnderLookup is not present in props and also from lookup cell we are not triggering click event so no need to check isUnderLookup
const showAsLongText = shouldShowAsLongText({ width, value, column: colObj })
if (colMeta?.display_type || !error || showAsLongText) {
if (colMeta?.display_type || !error) {
// Call the display type cell renderer's handleClick method if it exists
if (getDisplayValueCellRenderer(colObj, showAsLongText)?.handleClick) {
return getDisplayValueCellRenderer(colObj, showAsLongText).handleClick!({
if (getDisplayValueCellRenderer(colObj)?.handleClick) {
return getDisplayValueCellRenderer(colObj).handleClick!({
...props,
column: {
...column,
columnObj: {
...colObj,
uidt: colMeta?.display_type || UITypes.LongText,
uidt: colMeta?.display_type,
...colMeta.display_column_meta,
},
},
@@ -159,74 +187,82 @@ export const FormulaCellRenderer: CellRenderer = {
}
const result = isPg(column.columnObj.source_id) ? renderValue(handleTZ(props.value)) : renderValue(props.value)
const urls = replaceUrlsWithLink(result)
const padding = 10
const maxWidth = width - padding * 2
const pv = column.pv
const textColor = '#4a5268'
if (typeof urls === 'string') {
const texts = getFormulaTextSegments(urls)
const ctx = defaultOffscreen2DContext
ctx.font = `${pv ? 600 : 500} 13px Manrope`
ctx.fillStyle = pv ? '#3366FF' : textColor
const boxes = renderFormulaURL(ctx, {
texts,
height,
maxWidth,
x: x + padding,
y: y + 3,
lineHeight: 16,
underlineOffset: y < 36 ? 0 : 3,
})
const hoveredBox = boxes.find((box) => isBoxHovered(box, props.mousePosition))
if (hoveredBox) {
window.open(hoveredBox.url, '_blank')
if (column.columnObj?.colOptions?.parsed_tree?.dataType === FormulaDataTypes.STRING) {
const urls = replaceUrlsWithLink(result)
const padding = 10
const maxWidth = width - padding * 2
const pv = column.pv
// If CLicked on Expand icon
if (isBoxHovered({ x: x + width - 28, y: y + 7, width: 18, height: 18 }, mousePosition)) {
openDetachedLongText({ column: colObj, vModel: value })
return true
}
if (typeof urls === 'string') {
const ctx = defaultOffscreen2DContext
ctx.font = `${pv ? 600 : 500} 13px Manrope`
const boxes = renderFormulaURL(ctx, {
htmlText: urls,
height,
maxWidth,
x: x + padding,
y: y + 3,
lineHeight: 16,
})
// If clicked on url or other texts
// If clicked on URL, open the URL in a new tab
// If selected and clicked, open the detached long text
const hoveredBox = boxes.find((box) => isBoxHovered(box, props.mousePosition))
if (hoveredBox) {
confirmPageLeavingRedirect(hoveredBox.url, '_blank')
} else if (selected) {
openDetachedLongText({ column: colObj, vModel: value })
}
}
// If double-clicked on the cell, open the detached long text
if (props.event?.detail === 2) {
openDetachedLongText({ column: colObj, vModel: value })
return true
}
return true
}
// Todo: show inline warning
if (props.event?.detail === 2) {
showFieldEditWarning()
return true
}
return false
},
handleKeyDown: async (props) => {
const { column, value, makeCellEditable, row } = props
const { column, value, openDetachedLongText } = props
const colObj = column.columnObj
// Todo: show inline warning
if (props.e.key === 'Enter') {
// isUnderLookup is not present in props and also from lookup cell we are not triggering click event so no need to check isUnderLookup
const showAsLongText = shouldShowAsLongText({ width: parseInt(column.width) || 200, value, column: colObj })
if (showAsLongText) {
makeCellEditable(row.rowMeta.rowIndex!, column)
return true
if (props.e.key === 'Enter' || (props.e.key === ' ' && props.e.shiftKey)) {
if (!isDrawerOrModalExist() && colObj?.colOptions?.parsed_tree?.dataType === FormulaDataTypes.STRING) {
openDetachedLongText({ column: colObj, vModel: value })
return
}
if (props.e.key === ' ' && props.e.shiftKey) return
showFieldEditWarning()
return true
}
},
async handleHover(props) {
const { mousePosition, getCellPosition, column, row, value } = props
const { mousePosition, getCellPosition, column, row } = props
const colObj = column.columnObj
const colMeta = parseProp(colObj.meta)
const error = parseProp(colObj.colOptions)?.error ?? ''
const { tryShowTooltip, hideTooltip } = useTooltipStore()
hideTooltip()
const { width } = getCellPosition(column, props.row.rowMeta.rowIndex!)
// isUnderLookup is not present in props and also from lookup cell we are not triggering hover event so no need to check isUnderLookup
const showAsLongText = shouldShowAsLongText({ width, value, column: colObj })
if (colMeta?.display_type || !error || showAsLongText) {
return getDisplayValueCellRenderer(colObj, showAsLongText)?.handleHover?.({
if (colMeta?.display_type) {
return getDisplayValueCellRenderer(colObj)?.handleHover?.({
...props,
column: {
...column,
@@ -238,7 +274,18 @@ export const FormulaCellRenderer: CellRenderer = {
},
})
}
const { x, y } = getCellPosition(column, row.rowMeta.rowIndex!)
const { x, y, width } = getCellPosition(column, row.rowMeta.rowIndex!)
tryShowTooltip({
rect: {
x: x + width - 28,
y: y + 7,
width: 18,
height: 18,
},
mousePosition,
text: getI18n().global.t('tooltip.expandShiftSpace'),
})
tryShowTooltip({ rect: { x: x + 10, y, height: 25, width: 45 }, mousePosition, text: error })
},

View File

@@ -7,7 +7,8 @@ export const JsonCellRenderer: CellRenderer = {
const isHovered = isBoxHovered({ x, y, width, height }, mousePosition)
if (!value) {
// skip rendering text if undefined/null
if (ncIsUndefined(value) || ncIsNull(value)) {
if (isHovered) {
renderIconButton(ctx, {
buttonX: x + width - 28,
@@ -31,7 +32,16 @@ export const JsonCellRenderer: CellRenderer = {
}
}
const text = typeof value === 'string' ? value : JSON.stringify(value)
let text = typeof value === 'string' ? value : JSON.stringify(value)
// if invalid json string then stringify the value
if (typeof text === 'string') {
try {
JSON.parse(text)
} catch (e) {
text = JSON.stringify(text)
}
}
if (props.tag?.renderAsTag) {
return renderTagLabel(ctx, { ...props, text })
@@ -73,7 +83,7 @@ export const JsonCellRenderer: CellRenderer = {
const { e, row, column, makeCellEditable } = ctx
const columnObj = column.columnObj
if (columnObj.title && e.key.length === 1) {
if (columnObj.title && (e.key.length === 1 || isExpandCellKey(e))) {
makeCellEditable(row, column)
return true
}
@@ -98,6 +108,6 @@ export const JsonCellRenderer: CellRenderer = {
const { x, y, width } = getCellPosition(column, row.rowMeta.rowIndex!)
const expandIconBox = { x: x + width - 28, y: y + 7, width: 18, height: 18 }
tryShowTooltip({ text: getI18n().global.t('title.expand'), rect: expandIconBox, mousePosition })
tryShowTooltip({ text: getI18n().global.t('tooltip.expandShiftSpace'), rect: expandIconBox, mousePosition })
},
}

View File

@@ -146,6 +146,7 @@ export const BelongsToCellRenderer: CellRenderer = {
isPublic,
readonly,
isDoubleClick,
openDetachedExpandedForm,
}) {
const rowIndex = row.rowMeta.rowIndex!
const { x, y, width, height } = getCellPosition(column, rowIndex)
@@ -198,12 +199,10 @@ export const BelongsToCellRenderer: CellRenderer = {
*/
if (readonly) return true
const { open } = useExpandedFormDetached()
const rowId = extractPkFromRow(value, (column.relatedTableMeta?.columns || []) as ColumnType[])
if (rowId) {
open({
openDetachedExpandedForm({
isOpen: true,
row: { row: value, rowMeta: {}, oldRow: { ...value } },
meta: column.relatedTableMeta || ({} as TableType),

View File

@@ -2,8 +2,10 @@ import type { ColumnType, TableType } from 'nocodb-sdk'
import { isBoxHovered, renderIconButton, renderSingleLineText } from '../../utils/canvas'
import { PlainCellRenderer } from '../Plain'
import { renderAsCellLookupOrLtarValue } from '../../utils/cell'
import { getI18n } from '../../../../../../plugins/a.i18n'
const ellipsisWidth = 15
const buttonSize = 24
export const HasManyCellRenderer: CellRenderer = {
render: (ctx, props) => {
@@ -182,7 +184,6 @@ export const HasManyCellRenderer: CellRenderer = {
}
if (isBoxHovered({ x, y, width, height }, mousePosition)) {
const buttonSize = 24
const borderRadius = 6
if (!readonly) {
@@ -226,10 +227,10 @@ export const HasManyCellRenderer: CellRenderer = {
isPublic,
readonly,
isDoubleClick,
openDetachedExpandedForm,
}) {
const rowIndex = row.rowMeta.rowIndex!
const { x, y, width, height } = getCellPosition(column, rowIndex)
const buttonSize = 24
/**
* Note: The order of click action trigger is matter here to mimic behaviour of editable cell
@@ -273,12 +274,10 @@ export const HasManyCellRenderer: CellRenderer = {
*/
if (readonly) return true
const { open } = useExpandedFormDetached()
const rowId = extractPkFromRow(cellItem.value, (column.relatedTableMeta?.columns || []) as ColumnType[])
if (rowId) {
open({
openDetachedExpandedForm({
isOpen: true,
row: { row: cellItem.value, rowMeta: {}, oldRow: { ...cellItem.value } },
meta: column.relatedTableMeta || ({} as TableType),
@@ -307,4 +306,17 @@ export const HasManyCellRenderer: CellRenderer = {
return false
},
handleHover: async (props) => {
const { row, column, mousePosition, getCellPosition } = props
const { tryShowTooltip, hideTooltip } = useTooltipStore()
hideTooltip()
const rowIndex = row.rowMeta.rowIndex!
const { x, y, width } = getCellPosition(column, rowIndex)
const box = { x: x + width - 30, y: y + 4, width: buttonSize, height: buttonSize }
tryShowTooltip({ rect: box, mousePosition, text: getI18n().global.t('tooltip.expandShiftSpace') })
},
}

View File

@@ -2,8 +2,10 @@ import type { ColumnType, TableType } from 'nocodb-sdk'
import { isBoxHovered, renderIconButton, renderSingleLineText } from '../../utils/canvas'
import { PlainCellRenderer } from '../Plain'
import { renderAsCellLookupOrLtarValue } from '../../utils/cell'
import { getI18n } from '../../../../../../plugins/a.i18n'
const ellipsisWidth = 15
const buttonSize = 24
export const ManyToManyCellRenderer: CellRenderer = {
render: (ctx, props) => {
@@ -178,7 +180,6 @@ export const ManyToManyCellRenderer: CellRenderer = {
Object.assign(cellRenderStore, { ltar: returnData })
if (isBoxHovered({ x, y, width, height }, mousePosition)) {
const buttonSize = 24
const borderRadius = 6
if (!readonly) {
@@ -222,10 +223,10 @@ export const ManyToManyCellRenderer: CellRenderer = {
isPublic,
readonly,
isDoubleClick,
openDetachedExpandedForm,
}) {
const rowIndex = row.rowMeta.rowIndex!
const { x, y, width, height } = getCellPosition(column, rowIndex)
const buttonSize = 24
/**
* Note: The order of click action trigger is matter here to mimic behaviour of editable cell
@@ -269,11 +270,9 @@ export const ManyToManyCellRenderer: CellRenderer = {
*/
if (readonly) return true
const { open } = useExpandedFormDetached()
const rowId = extractPkFromRow(cellItem.value, (column.relatedTableMeta?.columns || []) as ColumnType[])
if (rowId) {
open({
openDetachedExpandedForm({
isOpen: true,
row: { row: cellItem.value, rowMeta: {}, oldRow: { ...cellItem.value } },
meta: column.relatedTableMeta || ({} as TableType),
@@ -302,4 +301,17 @@ export const ManyToManyCellRenderer: CellRenderer = {
return false
},
handleHover: async (props) => {
const { row, column, mousePosition, getCellPosition } = props
const { tryShowTooltip, hideTooltip } = useTooltipStore()
hideTooltip()
const rowIndex = row.rowMeta.rowIndex!
const { x, y, width } = getCellPosition(column, rowIndex)
const box = { x: x + width - 30, y: y + 4, width: buttonSize, height: buttonSize }
tryShowTooltip({ rect: box, mousePosition, text: getI18n().global.t('tooltip.expandShiftSpace') })
},
}

View File

@@ -155,6 +155,7 @@ export const OneToOneCellRenderer: CellRenderer = {
isPublic,
readonly,
isDoubleClick,
openDetachedExpandedForm,
}) {
const rowIndex = row.rowMeta.rowIndex!
const { x, y, width, height } = getCellPosition(column, rowIndex)
@@ -212,12 +213,10 @@ export const OneToOneCellRenderer: CellRenderer = {
*/
if (readonly) return true
const { open } = useExpandedFormDetached()
const rowId = extractPkFromRow(value, (column.relatedTableMeta?.columns || []) as ColumnType[])
if (rowId) {
open({
openDetachedExpandedForm({
isOpen: true,
row: { row: value, rowMeta: {}, oldRow: { ...value } },
meta: column.relatedTableMeta || ({} as TableType),

View File

@@ -24,4 +24,20 @@ export const LtarCellRenderer: CellRenderer = {
return cellRenderer?.handleClick?.(props)
}
},
handleKeyDown: async (props) => {
const { row, column, e, makeCellEditable } = props
if (isExpandCellKey(e)) {
makeCellEditable(row.rowMeta.rowIndex!, column)
return true
}
return false
},
handleHover: async (props) => {
const cellRenderer = getLtarCellRenderer(props.column.columnObj)
if (cellRenderer) {
return cellRenderer?.handleHover?.(props)
}
},
}

View File

@@ -75,4 +75,12 @@ export const LinksCellRenderer: CellRenderer = {
}
return false
},
async handleKeyDown({ row, column, e, makeCellEditable }) {
if (isExpandCellKey(e)) {
makeCellEditable(row.rowMeta.rowIndex!, column)
return true
}
return false
},
}

View File

@@ -51,6 +51,12 @@ export const LongTextCellRenderer: CellRenderer = {
if (props.tag?.renderAsTag) {
return renderTagLabel(ctx, { ...props, text, renderAsMarkdown: isRichMode })
} else if (isRichMode) {
// Begin clipping
ctx.save()
ctx.beginPath()
ctx.rect(x, y, width - padding, height) // Define the clipping rectangle
ctx.clip()
const { x: xOffset, y: yOffset } = renderMarkdown(ctx, {
x: x + padding,
y,
@@ -64,6 +70,9 @@ export const LongTextCellRenderer: CellRenderer = {
cellRenderStore: props.cellRenderStore,
})
// Restore context after clipping
ctx.restore()
if (!props.tag?.renderAsTag && isHovered) {
renderExpandIcon()
}
@@ -131,6 +140,12 @@ export const LongTextCellRenderer: CellRenderer = {
if (isAIPromptCol(column?.columnObj)) {
return AILongTextCellRenderer.handleKeyDown?.(ctx)
}
if (isExpandCellKey(e)) {
makeCellEditable(row.rowMeta!.rowIndex!, column)
return true
}
if (column.readonly || column.columnObj?.readonly) return
if (/^[a-zA-Z0-9]$/.test(e.key)) {
makeCellEditable(row.rowMeta!.rowIndex!, column)
@@ -174,7 +189,7 @@ export const LongTextCellRenderer: CellRenderer = {
const { x, y, width } = getCellPosition(column, row.rowMeta.rowIndex!)
const box = { x: x + width - 28, y: y + 7, width: 18, height: 18 }
tryShowTooltip({ rect: box, mousePosition, text: getI18n().global.t('title.expand') })
tryShowTooltip({ rect: box, mousePosition, text: getI18n().global.t('tooltip.expandShiftSpace') })
}
},
}

View File

@@ -272,7 +272,7 @@ export const LookupCellRenderer: CellRenderer = {
},
async handleKeyDown(ctx) {
const { e, row, column, makeCellEditable } = ctx
if (e.key === 'Enter') {
if (e.key === 'Enter' || isExpandCellKey(e)) {
makeCellEditable(row, column)
return true
}

View File

@@ -33,7 +33,7 @@ export const PercentCellRenderer: CellRenderer = {
renderSingleLineText(ctx, {
x: x + width - padding,
y,
text: value ? `${value}%` : '',
text: value !== null && typeof value !== 'undefined' && value !== '' ? `${value}%` : '',
textAlign: 'right',
maxWidth: width - padding * 2,
fontFamily: `${pv ? 600 : 500} 13px Manrope`,

View File

@@ -5,7 +5,7 @@ export const UrlCellRenderer: CellRenderer = {
render: (ctx, props) => {
const { value, x, y, column, width, height, selected, pv, padding, textColor = '#4a5268', spriteLoader, setCursor } = props
const text = value?.toString() ?? ''
const text = addMissingUrlSchma(value?.toString() ?? '')
if (!text) {
return {
@@ -68,7 +68,7 @@ export const UrlCellRenderer: CellRenderer = {
const { tryShowTooltip, hideTooltip } = useTooltipStore()
hideTooltip()
const text = value?.toString().trim() ?? ''
const text = addMissingUrlSchma(value?.toString() ?? '')
const isValid = text && isValidURL(text)
if (isValid || !text?.length) return
@@ -117,7 +117,7 @@ export const UrlCellRenderer: CellRenderer = {
const { x, y, width, height } = getCellPosition(column, row.rowMeta.rowIndex!)
const padding = 10
const text = value?.toString().trim() ?? ''
const text = addMissingUrlSchma(value?.toString() ?? '')
const isValid = text && isValidURL(text)
if (!isValid) return false

View File

@@ -2,6 +2,7 @@ import { type ColumnType, type TableType, UITypes, type UserType, type ViewType,
import { renderSingleLineText, renderSpinner } from '../utils/canvas'
import type { ActionManager } from '../loaders/ActionManager'
import type { ImageWindowLoader } from '../loaders/ImageLoader'
import { useDetachedLongText } from '../composables/useDetachedLongText'
import { EmailCellRenderer } from './Email'
import { SingleLineTextCellRenderer } from './SingleLineText'
import { LongTextCellRenderer } from './LongText'
@@ -55,7 +56,7 @@ export function useGridCellHandler(params: {
const { t } = useI18n()
const { metas } = useMetas()
const canvasCellEvents = reactive<ExtractInjectedReactive<typeof CanvasCellEventDataInj>>({})
const canvasCellEvents = reactive<CanvasCellEventDataInjType>({})
provide(CanvasCellEventDataInj, canvasCellEvents)
const baseStore = useBase()
@@ -64,6 +65,9 @@ export function useGridCellHandler(params: {
const { basesUser } = storeToRefs(useBases())
const { open: openDetachedExpandedForm } = useExpandedFormDetached()
const { open: openDetachedLongText } = useDetachedLongText()
const baseUsers = computed<(Partial<UserType> | Partial<User>)[]>(() =>
params.meta?.value?.base_id ? basesUser.value.get(params.meta?.value.base_id) || [] : [],
)
@@ -244,6 +248,8 @@ export function useGridCellHandler(params: {
const cellRenderStore = getCellRenderStore(`${ctx.column.id}-${ctx.pk}`)
canvasCellEvents.keyboardKey = ''
canvasCellEvents.event = undefined
if (cellHandler?.handleClick) {
return await cellHandler.handleClick({
...ctx,
@@ -255,6 +261,8 @@ export function useGridCellHandler(params: {
actionManager,
makeCellEditable,
isPublic: isPublic.value,
openDetachedExpandedForm,
openDetachedLongText,
})
}
return false
@@ -265,6 +273,7 @@ export function useGridCellHandler(params: {
const cellRenderStore = getCellRenderStore(`${ctx.column.id}-${ctx.pk}`)
canvasCellEvents.keyboardKey = ctx.e.key
canvasCellEvents.event = ctx.e
if (cellHandler?.handleKeyDown) {
return await cellHandler.handleKeyDown({
...ctx,
@@ -273,6 +282,7 @@ export function useGridCellHandler(params: {
updateOrSaveRow: params?.updateOrSaveRow,
actionManager,
makeCellEditable,
openDetachedLongText,
})
} else {
console.log('No handler found for cell type', ctx.column.columnObj.uidt)

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
import { useDetachedLongText } from '../composables/useDetachedLongText'
import ExpandedText from '../components/ExpandedText.vue'
const { states } = useDetachedLongText()
</script>
<template>
<template v-for="(state, i) of states" :key="`expanded-text-${i}`">
<ExpandedText v-model:model-visible="state.isOpen" v-model="state.vModel" :column="state.column" />
</template>
</template>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts">
import { type ColumnType, handleTZ } from 'nocodb-sdk'
const props = defineProps<{
column: ColumnType
modelVisible: boolean
modelValue: string
}>()
const emits = defineEmits(['update:modelVisible'])
const STORAGE_KEY = 'nc-long-text-expanded-modal-size'
const isVisible = useVModel(props, 'modelVisible', emits)
const inputWrapperRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLElement | null>(null)
const column = toRef(props, 'column')
const modelValue = toRef(props, 'modelValue')
const position = ref<{ top: number; left: number } | undefined>()
const mousePosition = ref<{ top: number; left: number } | undefined>()
const isDragging = ref(false)
const isSizeUpdated = ref(false)
const skipSizeUpdate = ref(true)
const onMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return
e.stopPropagation()
position.value = {
top: e.clientY - (mousePosition.value?.top || 0) > 0 ? e.clientY - (mousePosition.value?.top || 0) : position.value?.top || 0,
left:
e.clientX - (mousePosition.value?.left || 0) > -16
? e.clientX - (mousePosition.value?.left || 0)
: position.value?.left || 0,
}
}
const onMouseUp = (e: MouseEvent) => {
if (!isDragging.value) return
e.stopPropagation()
isDragging.value = false
position.value = undefined
mousePosition.value = undefined
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
const dragStart = (e: MouseEvent) => {
const dom = document.querySelector('.nc-long-text-expanded .ant-modal-content') as HTMLElement
if (!dom) return
mousePosition.value = {
top: e.clientY - dom.getBoundingClientRect().top,
left: e.clientX - dom.getBoundingClientRect().left + 16,
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
isDragging.value = true
}
watch(
position,
() => {
const dom = document.querySelector('.nc-long-text-expanded .ant-modal-content') as HTMLElement
if (!dom || !position.value) return
dom.style.transform = 'none'
dom.style.left = `${position.value.left}px`
dom.style.top = `${position.value.top}px`
},
{ deep: true },
)
watch(isVisible, (open) => {
if (!open) {
isSizeUpdated.value = false
skipSizeUpdate.value = true
}
})
const updateSize = () => {
try {
const size = localStorage.getItem(STORAGE_KEY)
const elem = inputRef.value as HTMLElement
const parsedJSON = size ? JSON.parse(size) : null
if (parsedJSON && elem?.style) {
elem.style.width = `${parsedJSON.width}px`
elem.style.height = `${parsedJSON.height}px`
}
} catch (e) {
console.error('Error updating size:', e)
}
}
const getResizeEl = () => {
return inputWrapperRef.value?.querySelector('.nc-long-text-expanded-textarea') as HTMLElement
}
useResizeObserver(inputWrapperRef, () => {
if (!isSizeUpdated.value) {
nextTick(() => {
until(() => !!getResizeEl())
.toBeTruthy()
.then(() => {
updateSize()
isSizeUpdated.value = true
})
})
return
}
if (skipSizeUpdate.value) {
skipSizeUpdate.value = false
return
}
const resizeEl = getResizeEl()
if (!resizeEl) return
const { width, height } = resizeEl.getBoundingClientRect()
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
width,
height,
}),
)
})
const { isPg } = useBase()
const result = isPg(column.value?.source_id) ? renderValue(handleTZ(modelValue.value)) : renderValue(modelValue.value)
const urls = replaceUrlsWithLink(result)
</script>
<template>
<a-modal
v-model:visible="isVisible"
:closable="false"
:footer="null"
:class="{ active: isVisible }"
wrap-class-name="nc-long-text-expanded"
:mask="true"
:mask-closable="false"
:mask-style="{ zIndex: 1051 }"
:z-index="1052"
>
<div
ref="inputWrapperRef"
class="flex flex-col pb-3 w-full relative"
:class="{ 'cursor-move': isDragging }"
@keydown.enter.stop
>
<div
v-if="column"
class="flex flex-row gap-x-1 items-center font-medium pl-3 pb-2.5 pt-3 border-b-1 border-gray-100 overflow-hidden cursor-move select-none"
@mousedown="dragStart"
>
<SmartsheetHeaderCellIcon :column-meta="column" class="flex" />
<div class="flex max-w-38">
<span class="truncate">
{{ column.title }}
</span>
</div>
<div class="flex-1" />
<NcButton class="mr-2" type="text" size="small" @click="isVisible = false">
<GeneralIcon icon="close" />
</NcButton>
</div>
<div class="p-3 pb-0 h-full">
<div
v-if="urls"
ref="inputRef"
:style="{
resize: 'both',
maxHeight: 'min(795px, 100vh - 170px)',
width: 'min(1256px, 100vw - 124px)',
}"
class="nc-long-text-expanded-textarea border-1 border-gray-200 bg-gray-50 !py-1 !px-3 !text-black !transition-none !cursor-text !min-h-[210px] !rounded-lg focus:border-brand-500 disabled:!bg-gray-50 nc-longtext-scrollbar"
v-html="urls"
></div>
<a-textarea
v-else
ref="inputRef"
disabled
:value="modelValue"
class="nc-long-text-expanded-textarea !py-1 !px-3 !text-black !transition-none !cursor-text !min-h-[210px] !rounded-lg focus:border-brand-500 disabled:!bg-gray-50 nc-longtext-scrollbar"
:placeholder="$t('activity.enterText')"
:style="{
resize: 'both',
maxHeight: 'min(795px, 100vh - 170px)',
width: 'min(1256px, 100vw - 124px)',
}"
@keydown.escape="isVisible = false"
@keydown.alt.stop
/>
</div>
</div>
</a-modal>
</template>
<style lang="scss">
.nc-long-text-expanded {
.ant-modal {
@apply !w-full h-full !top-0 !mx-auto !my-0;
.ant-modal-content {
@apply absolute w-[fit-content] min-h-70 min-w-70 !p-0 left-[50%] top-[50%];
/* Use 'transform' to center the div correctly */
transform: translate(-50%, -50%);
max-width: min(1280px, 100vw - 100px);
max-height: min(864px, 100vh - 100px);
.nc-longtext-scrollbar {
@apply scrollbar-thin scrollbar-thumb-gray-200 hover:scrollbar-thumb-gray-300 scrollbar-track-transparent;
}
}
}
}
.nc-long-text-expanded-textarea {
min-width: -webkit-fill-available;
max-width: min(1256px, 100vw - 126px);
transition-property: shadow, colors, border;
scrollbar-width: thin !important;
&::-webkit-scrollbar-thumb {
@apply rounded-lg;
}
}
</style>

View File

@@ -200,7 +200,7 @@ export function useCanvasRender({
})
}
const isRequired = colObj.rqd && !colObj.cdf
const isRequired = column.virtual ? isVirtualColRequired(colObj, meta.value?.columns || []) : colObj?.rqd && !colObj?.cdf
const availableTextWidth = width - (26 + iconSpace + (isRequired ? 4 : 0))
const truncatedText = truncateText(ctx, column.title!, availableTextWidth)
@@ -404,7 +404,7 @@ export function useCanvasRender({
})
}
const isRequired = colObj?.rqd && !colObj?.cdf
const isRequired = column.virtual ? isVirtualColRequired(colObj, meta.value?.columns || []) : colObj?.rqd && !colObj?.cdf
const availableTextWidth = width - (26 + iconSpace + (isRequired ? 4 : 0))
@@ -1341,25 +1341,26 @@ export function useCanvasRender({
ctx.restore()
} else if (isHovered) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset - scrollLeft.value, height.value - AGGREGATION_HEIGHT, width, AGGREGATION_HEIGHT)
ctx.fill()
ctx.clip()
ctx.font = '600 10px Manrope'
ctx.fillStyle = '#6a7184'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
const rightEdge = xOffset + width - 8 - scrollLeft.value
const textY = height.value - AGGREGATION_HEIGHT / 2
ctx.fillText('Summary', rightEdge, textY)
const textLen = ctx.measureText('Summary').width
if (!isLocked.value) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset - scrollLeft.value, height.value - AGGREGATION_HEIGHT, width, AGGREGATION_HEIGHT)
ctx.fill()
ctx.clip()
ctx.font = '600 10px Manrope'
ctx.fillStyle = '#6a7184'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
const rightEdge = xOffset + width - 8 - scrollLeft.value
const textY = height.value - AGGREGATION_HEIGHT / 2
ctx.fillText('Summary', rightEdge, textY)
const textLen = ctx.measureText('Summary').width
spriteLoader.renderIcon(ctx, {
icon: 'chevronDown',
size: 14,
@@ -1433,23 +1434,24 @@ export function useCanvasRender({
availWidth -= w
ctx.restore()
} else if (isHovered) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset, height.value - AGGREGATION_HEIGHT, mergedWidth, AGGREGATION_HEIGHT)
ctx.clip()
ctx.font = '600 10px Manrope'
ctx.textAlign = 'right'
const rightEdge = xOffset + mergedWidth - 8
const textY = height.value - AGGREGATION_HEIGHT / 2
ctx.fillText('Summary', rightEdge, textY)
const textLen = ctx.measureText('Summary').width
availWidth -= textLen
if (!isLocked.value) {
ctx.save()
ctx.beginPath()
ctx.rect(xOffset, height.value - AGGREGATION_HEIGHT, mergedWidth, AGGREGATION_HEIGHT)
ctx.clip()
ctx.font = '600 10px Manrope'
ctx.textAlign = 'right'
const rightEdge = xOffset + mergedWidth - 8
const textY = height.value - AGGREGATION_HEIGHT / 2
ctx.fillText('Summary', rightEdge, textY)
const textLen = ctx.measureText('Summary').width
availWidth -= textLen
spriteLoader.renderIcon(ctx, {
icon: 'chevronDown',
size: 14,

Some files were not shown because too many files have changed in this diff Show More