mirror of
https://github.com/nocodb/nocodb.git
synced 2026-02-02 02:57:23 +00:00
Merge pull request #10927 from nocodb/develop
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
220
packages/nc-gui/components/cell/Decimal/DecimalInput.vue
Normal file
220
packages/nc-gui/components/cell/Decimal/DecimalInput.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
79
packages/nc-gui/components/cell/Percent/ProgressBar.vue
Normal file
79
packages/nc-gui/components/cell/Percent/ProgressBar.vue
Normal 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>
|
||||
@@ -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 : ' ' }} </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.nc-cell:has(.progress-container) {
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
383
packages/nc-gui/components/dlg/Base/Duplicate.vue
Normal file
383
packages/nc-gui/components/dlg/Base/Duplicate.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
]"
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -144,8 +144,6 @@ const isVisibleDefaultValueInput = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const columnToValidate = [UITypes.Email, UITypes.URL, UITypes.PhoneNumber]
|
||||
|
||||
const onlyNameUpdateOnEditColumns = [
|
||||
UITypes.LinkToAnotherRecord,
|
||||
UITypes.Lookup,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}`],
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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') })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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') })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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') })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user