Browse Source

Merge pull request #7741 from nocodb/nc-feat/form-view-tbd-3

Nc feat/form view tbd 3
pull/7764/head
Raju Udava 9 months ago committed by GitHub
parent
commit
d8102d81c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      packages/nc-gui/assets/nc-icons/link.svg
  2. 103
      packages/nc-gui/components/cell/RichText.vue
  3. 32
      packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue
  4. 2
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  5. 2
      packages/nc-gui/components/dlg/TableDuplicate.vue
  6. 4
      packages/nc-gui/components/general/ColorPicker.vue
  7. 12
      packages/nc-gui/components/general/FormBanner.vue
  8. 12
      packages/nc-gui/components/general/ImageCropper.vue
  9. 25
      packages/nc-gui/components/project/AllTables.vue
  10. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  11. 410
      packages/nc-gui/components/smartsheet/Form.vue
  12. 50
      packages/nc-gui/components/smartsheet/form/LimitOptions.vue
  13. 9
      packages/nc-gui/lang/en.json
  14. 10
      packages/nc-gui/lib/types.ts
  15. 1
      packages/nc-gui/package.json
  16. 37
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue
  17. 57
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  18. 49
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  19. 2
      packages/nc-gui/utils/iconUtils.ts
  20. 13
      pnpm-lock.yaml
  21. 32
      tests/playwright/pages/Dashboard/Form/index.ts
  22. 1
      tests/playwright/tests/db/views/viewForm.spec.ts

8
packages/nc-gui/assets/nc-icons/link.svg

@ -1,4 +1,8 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.6665 9.16668C6.95281 9.54943 7.31808 9.86613 7.73754 10.0953C8.157 10.3245 8.62084 10.4608 9.0976 10.4949C9.57437 10.529 10.0529 10.4603 10.5007 10.2932C10.9486 10.1261 11.3552 9.86473 11.6932 9.52668L13.6932 7.52668C14.3004 6.89801 14.6363 6.056 14.6288 5.18201C14.6212 4.30802 14.2706 3.47198 13.6526 2.85395C13.0345 2.23592 12.1985 1.88536 11.3245 1.87777C10.4505 1.87017 9.60851 2.20615 8.97984 2.81335L7.83317 3.95335" stroke="#3366FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> <path
<path d="M9.33347 7.83332C9.04716 7.45057 8.68189 7.13387 8.26243 6.90469C7.84297 6.67552 7.37913 6.53924 6.90237 6.5051C6.4256 6.47095 5.94707 6.53974 5.49924 6.7068C5.0514 6.87386 4.64472 7.13527 4.3068 7.47332L2.3068 9.47332C1.69961 10.102 1.36363 10.944 1.37122 11.818C1.37881 12.692 1.72938 13.528 2.3474 14.146C2.96543 14.7641 3.80147 15.1146 4.67546 15.1222C5.54945 15.1298 6.39146 14.7938 7.02013 14.1867L8.16013 13.0467" stroke="#3366FF" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/> d="M6.6665 9.16668C6.95281 9.54943 7.31808 9.86613 7.73754 10.0953C8.157 10.3245 8.62084 10.4608 9.0976 10.4949C9.57437 10.529 10.0529 10.4603 10.5007 10.2932C10.9486 10.1261 11.3552 9.86473 11.6932 9.52668L13.6932 7.52668C14.3004 6.89801 14.6363 6.056 14.6288 5.18201C14.6212 4.30802 14.2706 3.47198 13.6526 2.85395C13.0345 2.23592 12.1985 1.88536 11.3245 1.87777C10.4505 1.87017 9.60851 2.20615 8.97984 2.81335L7.83317 3.95335"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M9.33347 7.83332C9.04716 7.45057 8.68189 7.13387 8.26243 6.90469C7.84297 6.67552 7.37913 6.53924 6.90237 6.5051C6.4256 6.47095 5.94707 6.53974 5.49924 6.7068C5.0514 6.87386 4.64472 7.13527 4.3068 7.47332L2.3068 9.47332C1.69961 10.102 1.36363 10.944 1.37122 11.818C1.37881 12.692 1.72938 13.528 2.3474 14.146C2.96543 14.7641 3.80147 15.1146 4.67546 15.1222C5.54945 15.1298 6.39146 14.7938 7.02013 14.1867L8.16013 13.0467"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

103
packages/nc-gui/components/cell/RichText.vue

@ -6,6 +6,7 @@ import TurndownService from 'turndown'
import { marked } from 'marked' import { marked } from 'marked'
import { generateJSON } from '@tiptap/html' import { generateJSON } from '@tiptap/html'
import Underline from '@tiptap/extension-underline' import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import { TaskItem } from '@/helpers/dbTiptapExtensions/task-item' import { TaskItem } from '@/helpers/dbTiptapExtensions/task-item'
import { Link } from '@/helpers/dbTiptapExtensions/links' import { Link } from '@/helpers/dbTiptapExtensions/links'
import { IsExpandedFormOpenInj, IsFormInj, ReadonlyInj, RowHeightInj } from '#imports' import { IsExpandedFormOpenInj, IsFormInj, ReadonlyInj, RowHeightInj } from '#imports'
@ -16,6 +17,10 @@ const props = defineProps<{
syncValueChange?: boolean syncValueChange?: boolean
showMenu?: boolean showMenu?: boolean
fullMode?: boolean fullMode?: boolean
isFormField?: boolean
autofocus?: boolean
placeholder?: string
renderAsText?: boolean
}>() }>()
const emits = defineEmits(['update:value']) const emits = defineEmits(['update:value'])
@ -28,6 +33,8 @@ const readOnlyCell = inject(ReadonlyInj, ref(false))
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const isFocused = ref(false)
const turndownService = new TurndownService({}) const turndownService = new TurndownService({})
turndownService.addRule('lineBreak', { turndownService.addRule('lineBreak', {
@ -105,13 +112,19 @@ const editorDom = ref<HTMLElement | null>(null)
const vModel = useVModel(props, 'value', emits, { defaultValue: '' }) const vModel = useVModel(props, 'value', emits, { defaultValue: '' })
const tiptapExtensions = [ const tiptapExtensions = [
StarterKit, StarterKit.configure({
heading: props.isFormField ? false : undefined,
}),
TaskList, TaskList,
TaskItem.configure({ TaskItem.configure({
nested: true, nested: true,
}), }),
Underline, Underline,
Link, Link,
Placeholder.configure({
emptyEditorClass: 'is-editor-empty',
placeholder: props.placeholder,
}),
] ]
const editor = useEditor({ const editor = useEditor({
@ -121,9 +134,18 @@ const editor = useEditor({
.turndown(editor.getHTML().replaceAll(/<p><\/p>/g, '<br />')) .turndown(editor.getHTML().replaceAll(/<p><\/p>/g, '<br />'))
.replaceAll(/\n\n<br \/>\n\n/g, '<br>\n\n') .replaceAll(/\n\n<br \/>\n\n/g, '<br>\n\n')
vModel.value = markdown vModel.value = props.isFormField && markdown === '<br />' ? '' : markdown
}, },
editable: !props.readOnly, editable: !props.readOnly,
autofocus: props.autofocus,
onFocus: () => {
isFocused.value = true
},
onBlur: (e) => {
if (!(e?.event?.relatedTarget as HTMLElement)?.closest('.bubble-menu, .nc-textarea-rich-editor')) {
isFocused.value = false
}
},
}) })
const setEditorContent = (contentMd: any, focusEndOfDoc?: boolean) => { const setEditorContent = (contentMd: any, focusEndOfDoc?: boolean) => {
@ -153,21 +175,43 @@ const setEditorContent = (contentMd: any, focusEndOfDoc?: boolean) => {
} }
if (props.syncValueChange) { if (props.syncValueChange) {
watch(vModel, () => { watch([vModel, editor], () => {
setEditorContent(vModel.value) setEditorContent(vModel.value)
}) })
} }
if (props.isFormField) {
watch([props, editor], () => {
if (props.readOnly) {
editor.value?.setEditable(false)
} else {
editor.value?.setEditable(true)
}
})
}
watch(editorDom, () => { watch(editorDom, () => {
if (!editorDom.value) return if (!editorDom.value) return
setEditorContent(vModel.value, true) setEditorContent(vModel.value, true)
if (props.isFormField) return
// Focus editor after editor is mounted // Focus editor after editor is mounted
setTimeout(() => { setTimeout(() => {
editor.value?.chain().focus().run() editor.value?.chain().focus().run()
}, 50) }, 50)
}) })
useEventListener(
editorDom,
'focusout',
(e: FocusEvent) => {
if (!(e?.relatedTarget as HTMLElement)?.closest('.bubble-menu, .nc-textarea-rich-editor')) {
isFocused.value = false
}
},
true,
)
</script> </script>
<template> <template>
@ -177,21 +221,26 @@ watch(editorDom, () => {
'flex flex-col flex-grow nc-rich-text-full': fullMode, 'flex flex-col flex-grow nc-rich-text-full': fullMode,
'nc-rich-text-embed flex flex-col pl-1 w-full': !fullMode, 'nc-rich-text-embed flex flex-col pl-1 w-full': !fullMode,
'readonly': readOnly, 'readonly': readOnly,
'nc-form-rich-text-field !p-0': isFormField,
}" }"
:tabindex="readOnlyCell ? -1 : 0" :tabindex="readOnlyCell || isFormField ? -1 : 0"
> >
<div v-if="renderAsText" class="truncate">
<span v-if="editor"> {{ editor?.getText() ?? '' }}</span>
</div>
<template v-else>
<div <div
v-if="showMenu && !readOnly" v-if="showMenu && !readOnly && !isFormField"
class="absolute top-0 right-0.5 xs:hidden" class="absolute top-0 right-0.5 xs:hidden"
:class="{ :class="{
'max-w-[calc(100%_-_198px)] flex justify-end rounded-tr-2xl overflow-hidden': fullMode, 'max-w-[calc(100%_-_198px)] flex justify-end rounded-tr-2xl overflow-hidden': fullMode,
}" }"
> >
<div class="nc-longtext-scrollbar"> <div class="nc-longtext-scrollbar">
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode /> <CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode :is-form-field="isFormField" />
</div> </div>
</div> </div>
<CellRichTextSelectedBubbleMenuPopup v-if="editor" :editor="editor" /> <CellRichTextSelectedBubbleMenuPopup v-if="editor && !isFormField" :editor="editor" />
<CellRichTextLinkOptions v-if="editor" :editor="editor" /> <CellRichTextLinkOptions v-if="editor" :editor="editor" />
<EditorContent <EditorContent
ref="editorDom" ref="editorDom"
@ -205,6 +254,18 @@ watch(editorDom, () => {
!fullMode && readOnly && rowHeight && !isExpandedFormOpen && !isForm, !fullMode && readOnly && rowHeight && !isExpandedFormOpen && !isForm,
}" }"
/> />
<div v-if="isFormField && !readOnly">
<div
class="overflow-hidden"
:class="isFocused ? 'max-h-[50px]' : 'max-h-0'"
:style="{
transition: 'max-height 0.2s ease-in-out',
}"
>
<CellRichTextSelectedBubbleMenu v-if="editor" :editor="editor" embed-mode is-form-field />
</div>
</div>
</template>
</div> </div>
</template> </template>
@ -223,8 +284,29 @@ watch(editorDom, () => {
.nc-rich-text-embed { .nc-rich-text-embed {
.ProseMirror { .ProseMirror {
@apply !border-transparent max-h-full; @apply !border-transparent max-h-full;
}
&:not(.nc-form-rich-text-field) {
.ProseMirror {
min-height: 8rem; min-height: 8rem;
} }
}
&.nc-form-rich-text-field {
.ProseMirror {
padding: 0;
}
&.readonly {
ul[data-type='taskList'] li input[type='checkbox'] {
background-color: #d5d5d9 !important;
&:not(:checked) {
@apply !border-gray-400;
}
&:focus {
box-shadow: none !important;
background-color: #d5d5d9 !important;
}
}
}
}
&.readonly { &.readonly {
.nc-textarea-rich-editor { .nc-textarea-rich-editor {
.ProseMirror { .ProseMirror {
@ -256,6 +338,13 @@ watch(editorDom, () => {
} }
.nc-textarea-rich-editor { .nc-textarea-rich-editor {
.tiptap p.is-editor-empty:first-child::before {
color: #6a7184;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.ProseMirror { .ProseMirror {
@apply flex-grow pt-1 border-1 border-gray-200 rounded-lg; @apply flex-grow pt-1 border-1 border-gray-200 rounded-lg;

32
packages/nc-gui/components/cell/RichText/SelectedBubbleMenu.vue

@ -14,13 +14,12 @@ import MsFormatQuote from '~icons/material-symbols/format-quote'
interface Props { interface Props {
editor: Editor editor: Editor
embedMode?: boolean embedMode?: boolean
isFormField?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const editor = computed(() => props.editor) const { editor, embedMode } = toRefs(props)
const embedMode = computed(() => props.embedMode)
const cmdOrCtrlKey = computed(() => { const cmdOrCtrlKey = computed(() => {
return isMac() ? '⌘' : 'CTRL' return isMac() ? '⌘' : 'CTRL'
@ -79,13 +78,15 @@ const onToggleLink = () => {
<template> <template>
<div <div
class="bubble-menu flex flex-row gap-x-1 bg-gray-100 py-1 rounded-lg px-1" class="bubble-menu flex-row gap-x-1 py-1 rounded-lg"
:class="{ :class="{
'inline-flex !bg-transparent': isFormField,
'flex bg-gray-100 px-1': !isFormField,
'embed-mode': embedMode, 'embed-mode': embedMode,
'full-mode': !embedMode, 'full-mode': !embedMode,
}" }"
> >
<NcTooltip :disabled="editor.isActive('codeBlock')"> <NcTooltip :placement="isFormField ? 'bottom' : undefined" :disabled="editor.isActive('codeBlock')">
<template #title> <template #title>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div> <div>
@ -99,13 +100,14 @@ const onToggleLink = () => {
type="text" type="text"
:class="{ 'is-active': editor.isActive('bold') }" :class="{ 'is-active': editor.isActive('bold') }"
:disabled="editor.isActive('codeBlock')" :disabled="editor.isActive('codeBlock')"
:tabindex="isFormField ? -1 : 0"
@click="editor!.chain().focus().toggleBold().run()" @click="editor!.chain().focus().toggleBold().run()"
> >
<MdiFormatBold /> <MdiFormatBold />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
<NcTooltip :disabled="editor.isActive('codeBlock')"> <NcTooltip :placement="isFormField ? 'bottom' : undefined" :disabled="editor.isActive('codeBlock')">
<template #title> <template #title>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div> <div>
@ -119,12 +121,13 @@ const onToggleLink = () => {
type="text" type="text"
:disabled="editor.isActive('codeBlock')" :disabled="editor.isActive('codeBlock')"
:class="{ 'is-active': editor.isActive('italic') }" :class="{ 'is-active': editor.isActive('italic') }"
:tabindex="isFormField ? -1 : 0"
@click=";(editor!.chain().focus() as any).toggleItalic().run()" @click=";(editor!.chain().focus() as any).toggleItalic().run()"
> >
<MdiFormatItalic /> <MdiFormatItalic />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
<NcTooltip :disabled="editor.isActive('codeBlock')"> <NcTooltip :placement="isFormField ? 'bottom' : undefined" :disabled="editor.isActive('codeBlock')">
<template #title> <template #title>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div> <div>
@ -139,12 +142,13 @@ const onToggleLink = () => {
type="text" type="text"
:class="{ 'is-active': editor.isActive('underline') }" :class="{ 'is-active': editor.isActive('underline') }"
:disabled="editor.isActive('codeBlock')" :disabled="editor.isActive('codeBlock')"
:tabindex="isFormField ? -1 : 0"
@click="editor!.chain().focus().toggleUnderline().run()" @click="editor!.chain().focus().toggleUnderline().run()"
> >
<MdiFormatUnderline /> <MdiFormatUnderline />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
<NcTooltip :disabled="editor.isActive('codeBlock')"> <NcTooltip :placement="isFormField ? 'bottom' : undefined" :disabled="editor.isActive('codeBlock')">
<template #title> <template #title>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<div> <div>
@ -158,11 +162,13 @@ const onToggleLink = () => {
type="text" type="text"
:class="{ 'is-active': editor.isActive('strike') }" :class="{ 'is-active': editor.isActive('strike') }"
:disabled="editor.isActive('codeBlock')" :disabled="editor.isActive('codeBlock')"
:tabindex="isFormField ? -1 : 0"
@click="editor!.chain().focus().toggleStrike().run()" @click="editor!.chain().focus().toggleStrike().run()"
> >
<MdiFormatStrikeThrough /> <MdiFormatStrikeThrough />
</NcButton> </NcButton>
</NcTooltip> </NcTooltip>
<template v-if="!isFormField">
<NcTooltip v-if="embedMode"> <NcTooltip v-if="embedMode">
<template #title> {{ $t('general.code') }}</template> <template #title> {{ $t('general.code') }}</template>
<NcButton <NcButton
@ -296,17 +302,20 @@ const onToggleLink = () => {
</NcTooltip> </NcTooltip>
<div class="divider"></div> <div class="divider"></div>
</template>
<NcTooltip :disabled="editor.isActive('codeBlock')"> <NcTooltip :placement="isFormField ? 'bottom' : undefined" :disabled="editor.isActive('codeBlock')">
<template #title> {{ $t('general.link') }}</template> <template #title> {{ $t('general.link') }}</template>
<NcButton <NcButton
size="small" size="small"
type="text" type="text"
:class="{ 'is-active': editor.isActive('link') }" :class="{ 'is-active': editor.isActive('link') }"
:disabled="editor.isActive('codeBlock')" :disabled="editor.isActive('codeBlock')"
:tabindex="isFormField ? -1 : 0"
@click="onToggleLink" @click="onToggleLink"
> >
<div class="flex flex-row items-center px-0.5"> <GeneralIcon v-if="isFormField" icon="link2"></GeneralIcon>
<div v-else class="flex flex-row items-center px-0.5">
<MdiLink /> <MdiLink />
<div class="!text-xs !ml-1">{{ $t('general.link') }}</div> <div class="!text-xs !ml-1">{{ $t('general.link') }}</div>
</div> </div>
@ -343,6 +352,9 @@ const onToggleLink = () => {
.bubble-menu.embed-mode { .bubble-menu.embed-mode {
@apply border-transparent !shadow-none; @apply border-transparent !shadow-none;
} }
.bubble-menu.form-field-mode {
@apply bg-transparent px-0;
}
.embed-mode.bubble-menu { .embed-mode.bubble-menu {
@apply !py-0 !my-0 !border-0; @apply !py-0 !my-0 !border-0;

2
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -284,6 +284,8 @@ const onProjectClick = async (base: NcProject, ignoreNavigation?: boolean, toggl
} }
if (!isProjectPopulated) { if (!isProjectPopulated) {
base.isLoading = false
const updatedProject = bases.value.get(base.id!)! const updatedProject = bases.value.get(base.id!)!
updatedProject.isLoading = false updatedProject.isLoading = false
} }

2
packages/nc-gui/components/dlg/TableDuplicate.vue

@ -144,7 +144,7 @@ const isEaster = ref(false)
{{ $t('general.duplicate') }} {{ $t('objects.table') }} {{ $t('general.duplicate') }} {{ $t('objects.table') }}
</div> </div>
<div class="mt-4">{{ $t('msg.warning.duplicateProject') }}</div> <div class="mt-4">{{ $t('msg.warning.duplicateTable') }}</div>
<div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div> <div class="prose-md self-center text-gray-500 mt-4">{{ $t('title.advancedSettings') }}</div>

4
packages/nc-gui/components/general/ColorPicker.vue

@ -117,6 +117,10 @@ watch(picked, (n, _o) => {
filter: brightness(90%); filter: brightness(90%);
-webkit-filter: brightness(90%); -webkit-filter: brightness(90%);
} }
.color-selector:focus.new-design {
outline: none;
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
}
.color-selector.selected.new-design { .color-selector.selected.new-design {
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe; box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
} }

12
packages/nc-gui/components/general/FormBanner.vue

@ -11,7 +11,8 @@ const { getPossibleAttachmentSrc } = useAttachment()
<template> <template>
<div <div
class="nc-form-banner-wrapper w-full mx-auto bg-white border-1 border-gray-200 rounded-2xl overflow-hidden" class="nc-form-banner-wrapper w-full mx-auto rounded-2xl overflow-hidden"
:class="!bannerImageUrl ? 'shadow-sm' : ''"
:style="{ aspectRatio: 4 / 1 }" :style="{ aspectRatio: 4 / 1 }"
> >
<LazyCellAttachmentImage <LazyCellAttachmentImage
@ -19,16 +20,15 @@ const { getPossibleAttachmentSrc } = useAttachment()
:srcs="getPossibleAttachmentSrc(parseProp(bannerImageUrl))" :srcs="getPossibleAttachmentSrc(parseProp(bannerImageUrl))"
class="nc-form-banner-image object-cover w-full" class="nc-form-banner-image object-cover w-full"
/> />
<!-- Todo: aspect ratio and cover image uploader and image cropper to crop image in fixed aspect ratio --> <div v-else class="h-full flex items-stretch justify-between bg-white">
<div v-else class="h-full flex items-stretch justify-between"> <div class="flex -mt-1">
<div class="flex">
<img src="~assets/img/form-banner-left.png" alt="form-banner-left'" /> <img src="~assets/img/form-banner-left.png" alt="form-banner-left'" />
</div> </div>
<div class="w-[91px] flex justify-center"> <div class="w-[91px] flex justify-center">
<img class="max-h-full self-center" src="~assets/img/icons/256x256.png" alt="form-banner-logo'" /> <img class="max-h-full self-center" src="~assets/img/icons/256x256.png" alt="form-banner-logo" />
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end -mb-1">
<img src="~assets/img/form-banner-right.png" alt="form-banner-left'" /> <img src="~assets/img/form-banner-right.png" alt="form-banner-left'" />
</div> </div>
</div> </div>

12
packages/nc-gui/components/general/ImageCropper.vue

@ -5,22 +5,21 @@ import 'vue-advanced-cropper/dist/theme.classic.css'
import type { AttachmentReqType } from 'nocodb-sdk' import type { AttachmentReqType } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useApi } from '#imports' import { extractSdkResponseErrorMsg, useApi } from '#imports'
import type { ImageCropperConfig } from '~/lib'
interface Props { interface Props {
imageConfig: { imageConfig: {
src: string src: string
type: string type: string
name: string name: string
} }
cropperConfig: { cropperConfig: ImageCropperConfig
aspectRatio?: number
}
uploadConfig?: { uploadConfig?: {
path?: string path?: string
} }
showCropper: boolean showCropper: boolean
} }
const { imageConfig, cropperConfig, uploadConfig, ...props } = defineProps<Props>() const { imageConfig, cropperConfig, uploadConfig, ...props } = defineProps<Props>()
const emit = defineEmits(['update:showCropper', 'submit']) const emit = defineEmits(['update:showCropper', 'submit'])
const showCropper = useVModel(props, 'showCropper', emit) const showCropper = useVModel(props, 'showCropper', emit)
@ -103,7 +102,10 @@ watch(showCropper, () => {
class="nc-cropper relative" class="nc-cropper relative"
:src="imageConfig.src" :src="imageConfig.src"
:auto-zoom="true" :auto-zoom="true"
:stencil-props="cropperConfig?.aspectRatio ? { aspectRatio: cropperConfig.aspectRatio } : {}" :stencil-props="cropperConfig?.stencilProps || {}"
:min-height="cropperConfig?.minHeight"
:min-width="cropperConfig?.minWidth"
:image-restriction="cropperConfig?.imageRestriction"
/> />
<div v-if="previewImage.src" class="result_preview"> <div v-if="previewImage.src" class="result_preview">
<img :src="previewImage.src" alt="Preview Image" /> <img :src="previewImage.src" alt="Preview Image" />

25
packages/nc-gui/components/project/AllTables.vue

@ -7,6 +7,8 @@ const { activeTables } = storeToRefs(useTablesStore())
const { openTable } = useTablesStore() const { openTable } = useTablesStore()
const { openedProject } = storeToRefs(useBases()) const { openedProject } = storeToRefs(useBases())
const { base } = useBase()
const isNewBaseModalOpen = ref(false) const isNewBaseModalOpen = ref(false)
const isDataSourceLimitReached = computed(() => Number(openedProject.value?.sources?.length) > 1) const isDataSourceLimitReached = computed(() => Number(openedProject.value?.sources?.length) > 1)
@ -78,7 +80,12 @@ const onCreateBaseClick = () => {
<template> <template>
<div class="nc-all-tables-view"> <div class="nc-all-tables-view">
<div class="flex flex-row gap-x-6 pb-3 pt-6"> <div
class="flex flex-row gap-x-6 pb-3 pt-6"
:class="{
'pointer-events-none': base?.isLoading,
}"
>
<div <div
v-if="isUIAllowed('tableCreate')" v-if="isUIAllowed('tableCreate')"
role="button" role="button"
@ -121,7 +128,21 @@ const onCreateBaseClick = () => {
</div> </div>
</component> </component>
</div> </div>
<template v-if="activeTables.length"> <div
v-if="base?.isLoading"
class="flex items-center justify-center text-center"
:style="{
height: 'calc(100vh - var(--topbar-height) - 18rem)',
}"
>
<div>
<GeneralLoader size="xlarge" />
<div class="mt-2">
{{ $t('general.loading') }}
</div>
</div>
</div>
<template v-else-if="activeTables.length">
<div class="flex flex-row w-full text-gray-400 border-b-1 border-gray-50 py-3 px-2.5"> <div class="flex flex-row w-full text-gray-400 border-b-1 border-gray-50 py-3 px-2.5">
<div class="w-2/5">{{ $t('objects.table') }}</div> <div class="w-2/5">{{ $t('objects.table') }}</div>
<div class="w-1/5">{{ $t('general.source') }}</div> <div class="w-1/5">{{ $t('general.source') }}</div>

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

@ -202,7 +202,7 @@ onUnmounted(() => {
{ {
'text-brand-500': isPrimary(column) && !props.virtual && !isForm && !isCalendar, 'text-brand-500': isPrimary(column) && !props.virtual && !isForm && !isCalendar,
'nc-grid-numeric-cell-right': isGrid && isNumericField && !isEditColumnMenu && !isForm && !isExpandedFormOpen, 'nc-grid-numeric-cell-right': isGrid && isNumericField && !isEditColumnMenu && !isForm && !isExpandedFormOpen,
'h-10': isForm && !isSurveyForm && !isAttachment(column) && !props.virtual, 'h-10': isForm && !isSurveyForm && !isAttachment(column) && !isTextArea(column) && !isJSON(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu, 'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!min-h-30': isTextArea(column) && (isForm || isSurveyForm), '!min-h-30': isTextArea(column) && (isForm || isSurveyForm),
}, },

410
packages/nc-gui/components/smartsheet/Form.vue

@ -44,6 +44,7 @@ import {
useViewData, useViewData,
watch, watch,
} from '#imports' } from '#imports'
import type { ImageCropperConfig } from '~/lib'
provide(IsFormInj, ref(true)) provide(IsFormInj, ref(true))
provide(IsGalleryInj, ref(false)) provide(IsGalleryInj, ref(false))
@ -64,6 +65,11 @@ const hiddenColTypes = [
UITypes.LastModifiedBy, UITypes.LastModifiedBy,
] ]
const enum NcForm {
heading = 'nc-form-heading',
subheading = 'nc-form-sub-heading',
}
const { isMobileMode, user } = useGlobal() const { isMobileMode, user } = useGlobal()
const formRef = ref() const formRef = ref()
@ -140,9 +146,7 @@ const imageCropperData = ref<{
type: string type: string
name: string name: string
} }
cropperConfig: { cropperConfig: ImageCropperConfig
aspectRatio?: number
}
uploadConfig?: { uploadConfig?: {
path?: string path?: string
} }
@ -153,7 +157,12 @@ const imageCropperData = ref<{
type: '', type: '',
name: '', name: '',
}, },
cropperConfig: {}, cropperConfig: {
stencilProps: {},
minHeight: 0,
minWidth: 0,
imageRestriction: 'none',
},
uploadConfig: { uploadConfig: {
path: '', path: '',
}, },
@ -273,22 +282,26 @@ async function onMove(event: any, isVisibleFormFields = false) {
visibleColumns.value[newIndex > oldIndex ? newIndex - 1 : newIndex < oldIndex ? newIndex + 1 : newIndex].fk_column_id, visibleColumns.value[newIndex > oldIndex ? newIndex - 1 : newIndex < oldIndex ? newIndex + 1 : newIndex].fk_column_id,
) )
} }
if (!localColumns.value.length || localColumns.value.length === 1) { if (!localColumns.value.length || localColumns.value.length === 1) {
element.order = 1 element.order = 1
} else if (localColumns.value.length - 1 === newIndex) { } else if (localColumns.value.length - 1 === newIndex) {
element.order = (localColumns.value[newIndex - 1]?.order || 0) + 1 element.order = Math.max(...localColumns.value.map((e) => e?.order ?? 0)) + 1
} else if (newIndex === 0) { } else if (newIndex === 0) {
element.order = (localColumns.value[1]?.order || 0) / 2 element.order = Math.min(...localColumns.value.map((e) => e?.order ?? 0)) / 2
} else { } else {
element.order = ((localColumns.value[newIndex - 1]?.order || 0) + (localColumns.value[newIndex + 1].order || 0)) / 2 element.order = ((localColumns.value[newIndex - 1]?.order ?? 0) + (localColumns.value[newIndex + 1].order ?? 0)) / 2
} }
await $api.dbView.formColumnUpdate(element.id, element) await $api.dbView.formColumnUpdate(element.id, element)
fields.value[fieldIndex] = element as any fields.value[fieldIndex] = element as any
// saveOrUpdate(element, fieldIndex) localColumns.value = localColumns.value.sort((a, b) => {
if (a.order !== undefined && b.order !== undefined) {
return a.order - b.order
}
return 0
})
$e('a:form-view:reorder') $e('a:form-view:reorder')
} }
@ -304,7 +317,6 @@ async function showOrHideColumn(column: Record<string, any>, show: boolean, isSi
const fieldIndex = fields.value?.findIndex((f) => f?.fk_column_id === column.fk_column_id) const fieldIndex = fields.value?.findIndex((f) => f?.fk_column_id === column.fk_column_id)
if (fieldIndex !== -1 && fieldIndex !== undefined && fields.value?.[fieldIndex]) { if (fieldIndex !== -1 && fieldIndex !== undefined && fields.value?.[fieldIndex]) {
console.log('column', column)
column.show = show column.show = show
await $api.dbView.formColumnUpdate(column.id, column) await $api.dbView.formColumnUpdate(column.id, column)
@ -333,7 +345,7 @@ function shouldSkipColumn(col: Record<string, any>) {
) )
} }
async function handleAddOrRemoveAllColumns(value: boolean) { async function handleAddOrRemoveAllColumns<T>(value: T) {
if (isLocked.value || !isEditable) return if (isLocked.value || !isEditable) return
if (value) { if (value) {
@ -381,6 +393,7 @@ function setFormData() {
meta: { meta: {
hide_branding: false, hide_branding: false,
background_color: '#F9F9FA', background_color: '#F9F9FA',
hide_banner: false,
...(parseProp(formViewData.value?.meta) ?? {}), ...(parseProp(formViewData.value?.meta) ?? {}),
}, },
} }
@ -464,19 +477,29 @@ const handleChangeBackground = (color: string) => {
} }
const openUploadImage = (isUploadBanner: boolean) => { const openUploadImage = (isUploadBanner: boolean) => {
if (!isEditable) return if (!isEditable || !isEeUI) return
imageCropperData.value.uploadConfig = { imageCropperData.value.uploadConfig = {
path: [NOCO, base.value.id, meta.value?.id, formViewData.value?.id].join('/'), path: [NOCO, base.value.id, meta.value?.id, formViewData.value?.id].join('/'),
} }
if (isUploadBanner) { if (isUploadBanner) {
imageCropperData.value.cropperConfig = { imageCropperData.value.cropperConfig = {
...imageCropperData.value.cropperConfig,
stencilProps: {
aspectRatio: 4 / 1, aspectRatio: 4 / 1,
},
minHeight: 100,
minWidth: 0,
} }
imageCropperData.value.cropFor = 'banner' imageCropperData.value.cropFor = 'banner'
} else { } else {
imageCropperData.value.cropperConfig = { imageCropperData.value.cropperConfig = {
...imageCropperData.value.cropperConfig,
stencilProps: {
aspectRatio: undefined, aspectRatio: undefined,
},
minHeight: 150,
minWidth: 150,
} }
imageCropperData.value.cropFor = 'logo' imageCropperData.value.cropFor = 'logo'
} }
@ -516,7 +539,7 @@ const handleOnUploadImage = (data: AttachmentResType = null) => {
onClickOutside(draggableRef, (e) => { onClickOutside(draggableRef, (e) => {
if ( if (
(e.target as HTMLElement)?.closest( (e.target as HTMLElement)?.closest(
'.nc-dropdown-single-select-cell, .nc-dropdown-multi-select-cell, .nc-dropdown-user-select-cell', '.nc-dropdown-single-select-cell, .nc-dropdown-multi-select-cell, .nc-dropdown-user-select-cell, .nc-form-rich-text-field',
) )
) { ) {
return return
@ -589,9 +612,13 @@ useEventListener(
(e: FocusEvent) => { (e: FocusEvent) => {
const nextActiveFieldTitle = const nextActiveFieldTitle =
(e?.relatedTarget as HTMLElement)?.getAttribute('data-title') || (e?.relatedTarget as HTMLElement)?.getAttribute('data-title') ||
(e?.relatedTarget as HTMLElement)?.offsetParent?.closest('.nc-form-focus-element')?.getAttribute('data-title') (e?.relatedTarget as HTMLElement)?.offsetParent?.closest('.nc-form-focus-element')?.getAttribute('data-title') ||
(e?.relatedTarget as HTMLElement)?.closest('.nc-form-focus-element')?.getAttribute('data-title')
if (activeRow.value && nextActiveFieldTitle && activeRow.value !== nextActiveFieldTitle) { if (
(activeRow.value || [NcForm.heading, NcForm.subheading].includes(nextActiveFieldTitle as NcForm)) &&
nextActiveFieldTitle &&
activeRow.value !== nextActiveFieldTitle
) {
if (isTabPressed.value) { if (isTabPressed.value) {
activeRow.value = nextActiveFieldTitle activeRow.value = nextActiveFieldTitle
} }
@ -613,6 +640,30 @@ useEventListener(
}, },
true, true,
) )
useEventListener(
document,
'keydown',
(e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
switch (e.key.toLowerCase()) {
case 's':
if (
cmdOrCtrl &&
!(
['input', 'textarea'].includes((e.target as any).nodeName.toLowerCase()) ||
(e.target as any)?.getAttribute('contenteditable')
)
) {
e.preventDefault()
updateView()
}
break
}
},
true,
)
</script> </script>
<template> <template>
@ -634,29 +685,45 @@ useEventListener(
:style="{ background: parseProp(formViewData?.meta)?.background_color || '#F9F9FA' }" :style="{ background: parseProp(formViewData?.meta)?.background_color || '#F9F9FA' }"
data-testid="nc-form-wrapper-submit" data-testid="nc-form-wrapper-submit"
> >
<GeneralFormBanner :banner-image-url="formViewData?.banner_image_url" /> <div class="max-w-[max(33%,688px)] mx-auto">
<GeneralFormBanner
v-if="!parseProp(formViewData?.meta).hide_banner"
:banner-image-url="formViewData?.banner_image_url"
/>
<div <div
class="transition-all duration-300 ease-in relative max-w-[max(33%,688px)] mx-auto my-6 bg-white rounded-3xl border-1 border-gray-200 px-4 py-8 lg:p-12 md:(p-8 dark:bg-slate-700)" class="transition-all duration-300 ease-in relative my-6 bg-white rounded-3xl border-1 border-gray-200 px-4 py-8 lg:p-12 md:(p-8 dark:bg-slate-700)"
> >
<div v-if="formViewData" class="items-center justify-center text-center mt-2"> <div v-if="formViewData" class="items-center justify-center text-left mt-2">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 mb-4"> <h1 class="text-2xl font-bold text-gray-900 mb-4">
{{ formViewData.heading }} {{ formViewData.heading }}
</h1> </h1>
<h2 v-if="formViewData.subheading" class="font-medium text-base text-gray-500 mb-4"> <div v-if="formViewData.subheading?.trim()">
{{ formViewData.subheading }} <LazyCellRichText
</h2> :value="formViewData.subheading"
class="font-medium text-base text-gray-500 !h-auto mb-4 -ml-1"
is-form-field
read-only
sync-value-change
/>
</div>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="w-full lg:w-[95%]"> <div class="w-full">
<a-alert type="success" class="!my-4 text-center !rounded-lg"> <a-alert class="!my-4 !py-4 text-left !rounded-lg" type="success" outlined>
<template #message> <template #message>
<div class="text-center"> <LazyCellRichText
{{ formViewData.success_msg || $t('msg.successfullySubmittedFormData') }} v-if="formViewData?.success_msg?.trim()"
</div> :value="formViewData?.success_msg"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/>
<span v-else> {{ $t('msg.successfullySubmittedFormData') }} </span>
</template> </template>
</a-alert> </a-alert>
@ -668,7 +735,7 @@ useEventListener(
}} }}
</div> </div>
<div v-if="formViewData.submit_another_form || !isPublic" class="text-center mt-4"> <div v-if="formViewData.submit_another_form || !isPublic" class="text-right mt-4">
<NcButton type="primary" size="medium" @click="submitted = false"> <NcButton type="primary" size="medium" @click="submitted = false">
{{ $t('activity.submitAnotherForm') }} {{ $t('activity.submitAnotherForm') }}
</NcButton> </NcButton>
@ -678,11 +745,19 @@ useEventListener(
</div> </div>
</div> </div>
</div> </div>
</div>
<div v-else class="h-full w-full flex" data-testid="nc-form-wrapper"> <div v-else class="nc-form-wrapper h-full w-full flex relative" data-testid="nc-form-wrapper">
<div v-if="isLoadingFormView" class="flex-1"></div> <div v-if="isLoadingFormView" class="flex-1 flex items-center justify-center text-center h-full">
<div>
<GeneralLoader size="xlarge" />
<div class="mt-2">
{{ $t('general.loading') }}
</div>
</div>
</div>
<template v-else-if="formViewData">
<div <div
v-else-if="formViewData"
class="flex-1 h-full overflow-auto nc-form-scrollbar p-6" class="flex-1 h-full overflow-auto nc-form-scrollbar p-6"
:style="{background:(formViewData?.meta as Record<string,any>).background_color || '#F9F9FA'}" :style="{background:(formViewData?.meta as Record<string,any>).background_color || '#F9F9FA'}"
> >
@ -696,15 +771,23 @@ useEventListener(
@submit="handleOnUploadImage" @submit="handleOnUploadImage"
></GeneralImageCropper> ></GeneralImageCropper>
<!-- cover image --> <!-- cover image -->
<div class="group relative max-w-[max(33%,688px)] mx-auto"> <div v-if="!parseProp(formViewData?.meta).hide_banner" class="group relative max-w-[max(33%,688px)] mx-auto">
<GeneralFormBanner :banner-image-url="formViewData.banner_image_url" /> <GeneralFormBanner :banner-image-url="formViewData.banner_image_url" />
<div class="absolute bottom-0 right-0 hidden group-hover:block"> <div class="absolute bottom-0 right-0 hidden group-hover:block">
<div class="flex items-center space-x-1 m-2"> <div class="flex items-center space-x-1 m-2">
<NcTooltip :disabled="isEeUI">
<template #title>
<div class="text-center">
{{ $t('msg.info.thisFeatureIsOnlyAvailableInEnterpriseEdition') }}
</div>
</template>
<NcButton <NcButton
type="secondary" type="secondary"
size="small" size="small"
class="nc-form-upload-banner-btn" class="nc-form-upload-banner-btn"
data-testid="nc-form-upload-banner-btn" data-testid="nc-form-upload-banner-btn"
:disabled="!isEeUI"
@click="openUploadImage(true)" @click="openUploadImage(true)"
> >
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
@ -715,7 +798,8 @@ useEventListener(
</span> </span>
</div> </div>
</NcButton> </NcButton>
<NcTooltip v-if="formViewData.banner_image_url"> </NcTooltip>
<NcTooltip v-if="isEeUI && formViewData.banner_image_url">
<template #title> {{ $t('general.delete') }} {{ $t('general.banner') }} </template> <template #title> {{ $t('general.delete') }} {{ $t('general.banner') }} </template>
<NcButton <NcButton
type="secondary" type="secondary"
@ -752,25 +836,36 @@ useEventListener(
<!-- Form logo --> <!-- Form logo -->
<div class="mb-4"> <div class="mb-4">
<div <div
class="nc-form-logo-wrapper mx-6 group relative rounded-xl inline-block h-56px max-w-189px overflow-hidden" class="nc-form-logo-wrapper mx-6 group relative inline-block h-56px overflow-hidden flex items-center"
:class="formViewData.logo_url ? 'hover:(w-full bg-gray-100)' : 'bg-gray-100'" :class="
formViewData.logo_url
? 'max-w-189px hover:(w-full bg-gray-100 rounded-xl) '
: 'bg-gray-100 max-w-147px rounded-xl'
"
style="transition: all 0.3s ease-in" style="transition: all 0.3s ease-in"
> >
<LazyCellAttachmentImage <LazyCellAttachmentImage
v-if="formViewData.logo_url" v-if="formViewData.logo_url"
:srcs="getPossibleAttachmentSrc(parseProp(formViewData.logo_url))" :srcs="getPossibleAttachmentSrc(parseProp(formViewData.logo_url))"
class="nc-form-logo !object-contain object-left max-h-full max-w-full !m-0 rounded-xl" class="flex-none nc-form-logo !object-contain object-left max-h-full max-w-full !m-0"
/> />
<div <div
class="items-center space-x-1 flex-nowrap m-3" class="items-center space-x-1 flex-nowrap m-3"
:class="formViewData.logo_url ? 'hidden absolute top-0 left-0 group-hover:flex' : 'flex'" :class="formViewData.logo_url ? 'hidden absolute top-0 left-0 group-hover:flex' : 'flex'"
> >
<NcTooltip :disabled="isEeUI">
<template #title>
<div class="text-center">
{{ $t('msg.info.thisFeatureIsOnlyAvailableInEnterpriseEdition') }}
</div>
</template>
<NcButton <NcButton
v-if="isEditable" v-if="isEditable"
type="secondary" type="secondary"
size="small" size="small"
class="nc-form-upload-logo-btn" class="nc-form-upload-logo-btn"
data-testid="nc-form-upload-log-btn" data-testid="nc-form-upload-log-btn"
:disabled="!isEeUI"
@click="openUploadImage(false)" @click="openUploadImage(false)"
> >
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
@ -778,7 +873,8 @@ useEventListener(
<span> {{ formViewData.logo_url ? $t('general.replace') : $t('general.upload') }} Logo</span> <span> {{ formViewData.logo_url ? $t('general.replace') : $t('general.upload') }} Logo</span>
</div> </div>
</NcButton> </NcButton>
<NcTooltip v-if="formViewData.logo_url"> </NcTooltip>
<NcTooltip v-if="isEeUI && formViewData.logo_url">
<template #title> {{ $t('general.delete') }} {{ $t('general.logo') }} </template> <template #title> {{ $t('general.delete') }} {{ $t('general.logo') }} </template>
<NcButton <NcButton
type="secondary" type="secondary"
@ -814,16 +910,16 @@ useEventListener(
'mb-4 py-0 lg:py-0': !isEditable, 'mb-4 py-0 lg:py-0': !isEditable,
}, },
{ {
'hover:bg-gray-50': activeRow !== 'nc-form-heading' && isEditable, 'hover:bg-gray-50': activeRow !== NcForm.heading && isEditable,
}, },
{ {
'bg-gray-50': activeRow === 'nc-form-heading' && isEditable, 'bg-gray-50': activeRow === NcForm.heading && isEditable,
}, },
{ {
'!hover:bg-white !ring-0 !cursor-auto': isLocked, '!hover:bg-white !ring-0 !cursor-auto': isLocked,
}, },
]" ]"
@click.stop="onFormItemClick({ title: 'nc-form-heading' })" @click.stop="onFormItemClick({ title: NcForm.heading })"
> >
<a-form-item v-if="isEditable" class="!my-0"> <a-form-item v-if="isEditable" class="!my-0">
<a-textarea <a-textarea
@ -841,8 +937,8 @@ useEventListener(
:disabled="isLocked" :disabled="isLocked"
placeholder="Form Title" placeholder="Form Title"
:bordered="false" :bordered="false"
data-testid="nc-form-heading" :data-testid="NcForm.heading"
data-title="nc-form-heading" :data-title="NcForm.heading"
@blur="updateView" @blur="updateView"
@keydown.enter="updateView" @keydown.enter="updateView"
/> />
@ -864,41 +960,36 @@ useEventListener(
'mb-4 py-0 lg:py-0': !isEditable, 'mb-4 py-0 lg:py-0': !isEditable,
}, },
{ {
'hover:bg-gray-50': activeRow !== 'nc-form-sub-heading' && isEditable, 'hover:bg-gray-50': activeRow !== NcForm.subheading && isEditable,
}, },
{ {
'bg-gray-50': activeRow === 'nc-form-sub-heading' && isEditable, 'bg-gray-50': activeRow === NcForm.subheading && isEditable,
}, },
{ {
'!hover:bg-white !ring-0 !cursor-auto': isLocked, '!hover:bg-white !ring-0 !cursor-auto': isLocked,
}, },
]" ]"
@click.stop="onFormItemClick({ title: 'nc-form-sub-heading' })" @click.stop="onFormItemClick({ title: NcForm.subheading })"
> >
<a-form-item v-if="isEditable" class="w-full !my-0"> <LazyCellRichText
<a-textarea v-if="isEditable && !isLocked"
v-model:value="formViewData.subheading" v-model:value="formViewData.subheading"
class="nc-form-focus-element w-full !px-0 !m-0 !border-0 !rounded-none font-medium text-base !text-gray-500"
:style="{
borderRightWidth: '0px !important',
resize: 'vertical',
}"
size="large"
auto-size
hide-details
:placeholder="$t('msg.info.formDesc')" :placeholder="$t('msg.info.formDesc')"
:bordered="false" class="nc-form-focus-element font-medium text-base !text-gray-500 -ml-1"
:disabled="!isEditable || isLocked" is-form-field
data-testid="nc-form-sub-heading" :autofocus="activeRow === NcForm.subheading"
data-title="nc-form-sub-heading" :data-testid="NcForm.subheading"
@blur="updateView" :data-title="NcForm.subheading"
@click="updateView" @update:value="updateView"
/>
<LazyCellRichText
v-else-if="formViewData.subheading"
:value="formViewData.subheading"
class="font-medium text-base !text-gray-500 -ml-1"
is-form-field
read-only
sync-value-change
/> />
</a-form-item>
<div v-else class="font-medium text-base text-gray-500">
{{ formViewData.subheading || '---' }}
</div>
</div> </div>
</div> </div>
@ -938,9 +1029,6 @@ useEventListener(
'!hover:bg-white !ring-0 !cursor-auto': isLocked, '!hover:bg-white !ring-0 !cursor-auto': isLocked,
}, },
]" ]"
:style="{
transition: 'height 1s ease-in',
}"
:data-title="element.title" :data-title="element.title"
data-testid="nc-form-fields" data-testid="nc-form-fields"
@click.stop="onFormItemClick(element)" @click.stop="onFormItemClick(element)"
@ -955,9 +1043,16 @@ useEventListener(
> >
</div> </div>
<div class="nc-form-help-text text-gray-500 text-sm mt-2" data-testid="nc-form-help-text"> <LazyCellRichText
{{ element.description }} v-if="element.description"
</div> :value="element.description"
is-form-field
read-only
sync-value-change
class="nc-form-help-text text-gray-500 text-sm mt-2 -ml-1"
data-testid="nc-form-help-text"
@update:value="updateColMeta(element)"
/>
</div> </div>
<!-- Field Header --> <!-- Field Header -->
@ -971,7 +1066,11 @@ useEventListener(
<SmartsheetHeaderCellIcon v-else :column-meta="element" /> <SmartsheetHeaderCellIcon v-else :column-meta="element" />
<NcTooltip class="truncate max-w-3/5" show-on-truncate-only> <NcTooltip class="truncate max-w-3/5" show-on-truncate-only>
<template #title> {{ element.title }} </template> <template #title>
<div class="text-center">
{{ element.title }}
</div>
</template>
<span data-testid="nc-form-input-label"> <span data-testid="nc-form-input-label">
{{ element.title }} {{ element.title }}
</span> </span>
@ -1023,19 +1122,16 @@ useEventListener(
/> />
</a-form-item> </a-form-item>
<a-form-item class="!my-0 !mb-3"> <a-form-item class="my-0 !mb-3">
<a-textarea <LazyCellRichText
v-model:value="element.description" v-model:value="element.description"
:rows="1" :placeholder="$t('msg.info.formHelpText')"
auto-size
hide-details
class="form-meta-input nc-form-input-help-text" class="form-meta-input nc-form-input-help-text"
is-form-field
data-testid="nc-form-input-help-text" data-testid="nc-form-input-help-text"
:placeholder="$t('msg.info.formHelpText')" @update:value="updateColMeta(element)"
@keydown.enter.prevent /></a-form-item>
@change="updateColMeta(element)"
/>
</a-form-item>
<a-form-item <a-form-item
v-if="columnSupportsScanning(element.uidt)" v-if="columnSupportsScanning(element.uidt)"
class="!my-0 !mb-3 nc-form-input-enable-scanner-form-item" class="!my-0 !mb-3 nc-form-input-enable-scanner-form-item"
@ -1112,7 +1208,7 @@ useEventListener(
<!-- Field Settings --> <!-- Field Settings -->
<div <div
v-if="activeRow === element.title" v-if="activeRow === element.title && isSelectTypeCol(element.uidt)"
class="nc-form-field-settings border-t border-gray-200 p-4 lg:p-6 flex flex-col gap-3" class="nc-form-field-settings border-t border-gray-200 p-4 lg:p-6 flex flex-col gap-3"
> >
<!-- Layout --> <!-- Layout -->
@ -1120,9 +1216,14 @@ useEventListener(
<div>Layout</div> <div>Layout</div>
<a-radio-group <a-radio-group
v-model:value="element.meta.isList" :value="!!element.meta.isList"
class="nc-form-field-layout !mt-2" class="nc-form-field-layout !mt-2"
@change="updateColMeta(element)" @update:value="
(value) => {
element.meta.isList = value
updateColMeta(element)
}
"
> >
<a-radio :value="false">{{ $t('general.dropdown') }}</a-radio> <a-radio :value="false">{{ $t('general.dropdown') }}</a-radio>
<a-radio :value="true">{{ $t('general.list') }}</a-radio> <a-radio :value="true">{{ $t('general.list') }}</a-radio>
@ -1131,7 +1232,7 @@ useEventListener(
<!-- Todo: Show on conditions,... --> <!-- Todo: Show on conditions,... -->
<!-- eslint-disable vue/no-constant-condition --> <!-- eslint-disable vue/no-constant-condition -->
<div v-if="false" class="flex items-start gap-3 px-3 py-2 border-1 border-gray-200 rounded-lg"> <div v-if="false" class="flex items-start gap-3 px-3 py-2 border-1 border-gray-200 rounded-lg">
<a-switch v-e="['a:form-view:field:show-on-condition']" size="small" /> <a-switch v-e="['a:form-view:field:show-on-condition']" size="small" class="nc-form-switch-focus" />
<div> <div>
<div class="font-medium text-gray-800">{{ $t('labels.showOnConditions') }}</div> <div class="font-medium text-gray-800">{{ $t('labels.showOnConditions') }}</div>
<div class="text-gray-500">{{ $t('labels.showFieldOnConditionsMet') }}</div> <div class="text-gray-500">{{ $t('labels.showFieldOnConditionsMet') }}</div>
@ -1145,6 +1246,7 @@ useEventListener(
v-model:checked="element.meta.isLimitOption" v-model:checked="element.meta.isLimitOption"
v-e="['a:form-view:field:limit-options']" v-e="['a:form-view:field:limit-options']"
size="small" size="small"
class="nc-form-switch-focus"
@change="updateColMeta(element)" @change="updateColMeta(element)"
/> />
<div class="font-medium text-gray-800">{{ $t('labels.limitOptions') }}</div> <div class="font-medium text-gray-800">{{ $t('labels.limitOptions') }}</div>
@ -1155,6 +1257,7 @@ useEventListener(
<LazySmartsheetFormLimitOptions <LazySmartsheetFormLimitOptions
v-model:model-value="element.meta.limitOptions" v-model:model-value="element.meta.limitOptions"
:column="element" :column="element"
:is-required="isRequired(element, element.required)"
@update:model-value="updateColMeta(element)" @update:model-value="updateColMeta(element)"
></LazySmartsheetFormLimitOptions> ></LazySmartsheetFormLimitOptions>
</div> </div>
@ -1291,8 +1394,10 @@ useEventListener(
<div class="flex-1 flex flex-row items-center truncate cursor-pointer"> <div class="flex-1 flex flex-row items-center truncate cursor-pointer">
<div class="flex-1 font-base my-1.5">{{ $t('activity.selectAllFields') }}</div> <div class="flex-1 font-base my-1.5">{{ $t('activity.selectAllFields') }}</div>
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<NcSwitch <a-switch
:checked="visibleColumns.length === localColumns.length" :checked="visibleColumns.length === localColumns.length"
size="small"
class="nc-switch"
@change="handleAddOrRemoveAllColumns" @change="handleAddOrRemoveAllColumns"
/> />
</div> </div>
@ -1331,7 +1436,9 @@ useEventListener(
<div class="ml-1 inline-block max-w-1/2"> <div class="ml-1 inline-block max-w-1/2">
<NcTooltip class="truncate text-sm" :disabled="drag" show-on-truncate-only> <NcTooltip class="truncate text-sm" :disabled="drag" show-on-truncate-only>
<template #title> <template #title>
<div class="text-center">
{{ field.title }} {{ field.title }}
</div>
</template> </template>
<span data-testid="nc-field-title"> {{ field.title }} </span> <span data-testid="nc-field-title"> {{ field.title }} </span>
</NcTooltip> </NcTooltip>
@ -1340,16 +1447,25 @@ useEventListener(
<span>&nbsp;(</span> <span>&nbsp;(</span>
<NcTooltip class="truncate" :disabled="drag" show-on-truncate-only> <NcTooltip class="truncate" :disabled="drag" show-on-truncate-only>
<template #title> <template #title>
<div class="text-center">
{{ field.label }} {{ field.label }}
</div>
</template> </template>
<span data-testid="nc-field-title ">{{ field.label?.trim() }}</span> <span data-testid="nc-field-title ">{{ field.label?.trim() }}</span>
</NcTooltip> </NcTooltip>
<span>)</span> <span>)</span>
</div> </div>
<span v-if="isRequired(field, field.required)" class="text-red-500 text-sm align-top">&nbsp;*</span> <span v-if="isRequired(field, field.required)" class="text-red-500 text-sm align-top"
>&nbsp;*</span
>
</div> </div>
</div> </div>
<NcSwitch :checked="!!field.show" :disabled="field.required || isLocked || !isEditable" /> <a-switch
:checked="!!field.show"
:disabled="field.required || isLocked || !isEditable"
class="nc-switch"
size="small"
/>
</div> </div>
</div> </div>
</template> </template>
@ -1408,7 +1524,7 @@ useEventListener(
<!-- Hide NocoDB Branding --> <!-- Hide NocoDB Branding -->
<a-switch <a-switch
v-if="isEeUI" v-if="isEeUI"
v-e="[`a:form-view:submit-another-form`]" v-e="[`a:form-view:hide-branding`]"
:checked="parseProp(formViewData.meta)?.hide_branding" :checked="parseProp(formViewData.meta)?.hide_branding"
size="small" size="small"
class="nc-form-hide-branding" class="nc-form-hide-branding"
@ -1423,14 +1539,37 @@ useEventListener(
} }
" "
/> />
<a-tooltip v-else placement="top"> <NcTooltip v-else placement="top">
<template #title> <template #title>
{{ $t('msg.info.upgradeToEnterpriseEdition', { extraInfo: `to ${$t('labels.hideNocodbBranding')}` }) }} <div class="text-center">
{{ $t('msg.info.thisFeatureIsOnlyAvailableInEnterpriseEdition') }}
</div>
</template> </template>
<a-switch :checked="false" size="small" :disabled="true" /> <a-switch :checked="false" size="small" :disabled="true" />
</a-tooltip> </NcTooltip>
<span class="ml-4">{{ $t('labels.hideNocodbBranding') }}</span> <span class="ml-4">{{ $t('labels.hideNocodbBranding') }}</span>
</div> </div>
<div class="flex items-center">
<!-- Hide Banner -->
<a-switch
v-e="[`a:form-view:hide-banner`]"
:checked="parseProp(formViewData.meta)?.hide_banner"
size="small"
class="nc-form-hide-banner"
data-testid="nc-form-hide-banner"
:disabled="isLocked || !isEditable"
@change="
(value) => {
if (isLocked || !isEditable) return
(formViewData!.meta as Record<string,any>).hide_banner = value
updateView()
}
"
/>
<span class="ml-4">{{ $t('general.hide') }} {{ $t('general.banner') }}</span>
</div>
</div> </div>
<div class="p-4 flex flex-col space-y-4"> <div class="p-4 flex flex-col space-y-4">
@ -1444,15 +1583,22 @@ useEventListener(
<div class="text-gray-800 mb-2"> <div class="text-gray-800 mb-2">
{{ $t('msg.info.formDisplayMessage') }} {{ $t('msg.info.formDisplayMessage') }}
</div> </div>
<a-textarea <a-form-item class="!my-0">
<LazyCellRichText
v-if="!isLocked && isEditable"
v-model:value="formViewData.success_msg" v-model:value="formViewData.success_msg"
:rows="3" class="nc-form-after-submit-msg"
hide-details :is-form-field="true"
class="nc-form-after-submit-msg !rounded-lg !px-3 !py-1"
data-testid="nc-form-after-submit-msg" data-testid="nc-form-after-submit-msg"
:disabled="isLocked || !isEditable" @update:value="updateView" />
@change="updateView" <LazyCellRichText
/> v-else
:value="formViewData.success_msg"
class="nc-form-after-submit-msg"
is-form-field
read-only
data-testid="nc-form-after-submit-msg"
/></a-form-item>
</div> </div>
<!-- Other options --> <!-- Other options -->
@ -1508,6 +1654,7 @@ useEventListener(
</Pane> </Pane>
</Splitpanes> </Splitpanes>
</div> </div>
</template>
</div> </div>
</template> </template>
<div <div
@ -1538,7 +1685,7 @@ useEventListener(
.nc-input { .nc-input {
@apply appearance-none w-full; @apply appearance-none w-full;
&:not(.layout-list) { &:not(.layout-list) {
@apply !bg-white rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500; @apply !bg-white rounded-lg border-solid border-1 border-gray-200 !focus-within:border-brand-500;
} }
&.layout-list { &.layout-list {
@apply h-auto !pl-0 !py-1; @apply h-auto !pl-0 !py-1;
@ -1556,10 +1703,6 @@ useEventListener(
} }
&:not(.nc-cell-longtext) { &:not(.nc-cell-longtext) {
@apply px-2 py-2; @apply px-2 py-2;
:deep(textarea) {
@apply !p-2;
}
} }
&.nc-cell-json { &.nc-cell-json {
@ -1568,20 +1711,17 @@ useEventListener(
@apply w-full; @apply w-full;
} }
} }
}
.form-meta-input { :deep(.ant-picker) {
@apply !rounded-lg !text-sm; @apply !py-0;
&::placeholder { }
@apply !text-gray-500; :deep(input.nc-cell-field) {
@apply !py-0;
} }
} }
.nc-form-input-label { .nc-form-input-label {
@apply !px-4 !py-2 font-semibold text-gray-800; @apply !px-4 !py-2 font-semibold text-gray-800 !rounded-lg !text-sm;
}
.nc-form-input-help-text {
@apply !px-4 !py-1 text-gray-700;
} }
.nc-form-help-text, .nc-form-help-text,
@ -1655,6 +1795,9 @@ useEventListener(
:deep(.nc-form-input-required + button):focus { :deep(.nc-form-input-required + button):focus {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff; box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
} }
:deep(.nc-form-switch-focus):focus {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
}
.nc-form-field-layout { .nc-form-field-layout {
@apply !flex !items-center w-full space-x-3; @apply !flex !items-center w-full space-x-3;
@ -1662,6 +1805,43 @@ useEventListener(
@apply border-1 border-gray-200 rounded-lg !py-2 !px-3 basis-full !mr-0 !items-center; @apply border-1 border-gray-200 rounded-lg !py-2 !px-3 basis-full !mr-0 !items-center;
.ant-radio { .ant-radio {
@apply !top-0; @apply !top-0;
&:focus-within .ant-radio-inner {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
}
}
}
}
.nc-form-wrapper {
.ant-switch:focus,
.ant-switch-checked:focus {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
&:hover {
box-shadow: none;
}
}
}
</style>
<style lang="scss">
.form-meta-input {
.nc-textarea-rich-editor {
@apply pl-3 pr-4 !rounded-lg !text-sm border-1 border-gray-200 focus-within:border-brand-500;
}
&.nc-form-input-label .nc-textarea-rich-editor {
@apply pt-2 pb-1 font-semibold text-gray-800;
}
&.nc-form-input-help-text .nc-textarea-rich-editor {
@apply pt-1 text-gray-700;
}
}
.nc-form-after-submit-msg {
.nc-textarea-rich-editor {
@apply pl-1 pr-2 pt-2 pb-1 !rounded-lg !text-sm border-1 border-gray-200 focus-within:border-brand-500;
.ProseMirror {
min-height: 5rem;
} }
} }
} }

50
packages/nc-gui/components/smartsheet/form/LimitOptions.vue

@ -9,6 +9,7 @@ import { MetaInj, iconMap } from '#imports'
const props = defineProps<{ const props = defineProps<{
modelValue: FormFieldsLimitOptionsType[] modelValue: FormFieldsLimitOptionsType[]
column: ColumnType column: ColumnType
isRequired?: boolean
}>() }>()
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
@ -121,6 +122,18 @@ async function onMove(_event: { moved: { newIndex: number; oldIndex: number; ele
vModel.value = [...vModel.value] vModel.value = [...vModel.value]
} }
const showOrHideAll = (showAll: boolean) => {
if (props.isRequired && !showAll) {
return
}
vModel.value = vModel.value.map((o) => {
return {
...o,
show: showAll,
}
})
}
</script> </script>
<template> <template>
@ -147,13 +160,42 @@ async function onMove(_event: { moved: { newIndex: number; oldIndex: number; ele
</template> </template>
</a-input> </a-input>
</div> </div>
<div v-if="vModel.length" class="flex items-stretch gap-2 pr-2 pl-3 py-1.5 rounded-t-lg border-1 border-b-0 border-gray-200">
<NcTooltip class="truncate max-w-full" :disabled="!isRequired">
<template #title> {{ $t('msg.info.preventHideAllOptions') }} </template>
<NcButton
type="secondary"
size="xxsmall"
class="!border-none !px-2 !text-xs !text-gray-500 !disabled:text-gray-300"
:disabled="isRequired || vModel.filter((o) => !o.show).length === vModel.length"
@click="showOrHideAll(false)"
>
Hide all
</NcButton>
</NcTooltip>
<div>
<NcButton
type="secondary"
size="xxsmall"
class="!border-none !px-2 !text-xs !text-gray-500 !disabled:text-gray-300"
:disabled="vModel.filter((o) => o.show).length === vModel.length"
@click="showOrHideAll(true)"
>
Show all
</NcButton>
</div>
</div>
<Draggable <Draggable
v-if="vModel.length" v-if="vModel.length"
:model-value="vModel" :model-value="vModel"
item-key="id" item-key="id"
handle=".nc-child-draggable-icon" handle=".nc-child-draggable-icon"
ghost-class="nc-form-field-limit-option-ghost" ghost-class="nc-form-field-limit-option-ghost"
class="rounded-lg border-1 border-gray-200 !max-h-[224px] overflow-y-auto nc-form-scrollbar" class="rounded-b-lg border-1 border-gray-200 !max-h-[224px] overflow-y-auto nc-form-scrollbar"
@change="onMove($event)" @change="onMove($event)"
@start="drag = true" @start="drag = true"
@end="drag = false" @end="drag = false"
@ -175,9 +217,14 @@ async function onMove(_event: { moved: { newIndex: number; oldIndex: number; ele
> >
<component :is="iconMap.drag" class="nc-child-draggable-icon flex-none cursor-move !h-4 !w-4 text-gray-600" /> <component :is="iconMap.drag" class="nc-child-draggable-icon flex-none cursor-move !h-4 !w-4 text-gray-600" />
<NcTooltip :disabled="!isRequired || !(element.show && isRequired && vModel.filter((o) => o.show).length === 1)">
<template #title> {{ $t('msg.info.preventHideAllOptions') }} </template>
<div <div
class="!border-none !px-2"
@click=" @click="
() => { () => {
if (element.show && isRequired && vModel.filter((o) => o.show).length === 1) return
element.show = !element.show element.show = !element.show
vModel = [...vModel] vModel = [...vModel]
} }
@ -188,6 +235,7 @@ async function onMove(_event: { moved: { newIndex: number; oldIndex: number; ele
class="flex-none cursor-pointer !h-4 !w-4 text-gray-600" class="flex-none cursor-pointer !h-4 !w-4 text-gray-600"
/> />
</div> </div>
</NcTooltip>
<a-tag v-if="column.uidt === UITypes.User" class="rounded-tag max-w-[calc(100%_-_70px)] !pl-0" color="'#ccc'"> <a-tag v-if="column.uidt === UITypes.User" class="rounded-tag max-w-[calc(100%_-_70px)] !pl-0" color="'#ccc'">
<span <span

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

@ -694,9 +694,9 @@
"appearanceSettings": "Appearance Settings", "appearanceSettings": "Appearance Settings",
"backgroundColor": "Background Color", "backgroundColor": "Background Color",
"hideNocodbBranding": "Hide NocoDB Branding", "hideNocodbBranding": "Hide NocoDB Branding",
"showOnConditions": "Show on condtions", "showOnConditions": "Show on conditions",
"showFieldOnConditionsMet": "Shows field only when conditions are met", "showFieldOnConditionsMet": "Shows field only when conditions are met",
"limitOptions": "Limit ptions", "limitOptions": "Limit options",
"limitOptionsSubtext": "Limit options visible to users by selecting available options", "limitOptionsSubtext": "Limit options visible to users by selecting available options",
"clearSelection": "Clear selection" "clearSelection": "Clear selection"
}, },
@ -1181,6 +1181,7 @@
"formInput": "Enter form input label", "formInput": "Enter form input label",
"formHelpText": "Add some help text", "formHelpText": "Add some help text",
"onlyCreator": "Only visible to Creator", "onlyCreator": "Only visible to Creator",
"formTitle": "Add form Title",
"formDesc": "Add form description", "formDesc": "Add form description",
"beforeEnablePwd": "Restrict access with a password", "beforeEnablePwd": "Restrict access with a password",
"afterEnablePwd": "Access is password restricted", "afterEnablePwd": "Access is password restricted",
@ -1305,8 +1306,10 @@
"groupPasteIsNotSupportedOnLinksColumn": "Group paste operation is not supported on Links/LinkToAnotherRecord column", "groupPasteIsNotSupportedOnLinksColumn": "Group paste operation is not supported on Links/LinkToAnotherRecord column",
"groupClearIsNotSupportedOnLinksColumn": "Group clear operation is not supported on Links/LinkToAnotherRecord column", "groupClearIsNotSupportedOnLinksColumn": "Group clear operation is not supported on Links/LinkToAnotherRecord column",
"upgradeToEnterpriseEdition": "Upgrade to Enterprise Edition {extraInfo}", "upgradeToEnterpriseEdition": "Upgrade to Enterprise Edition {extraInfo}",
"thisFeatureIsOnlyAvailableInEnterpriseEdition": "This feature is only available in enterprise edition",
"yourCurrentRoleIs": "Your current role is", "yourCurrentRoleIs": "Your current role is",
"pleaseRequestAccessForView": "Please request for higher permission from the Admin / Base owner / Workspace owner to get access to this {viewName}" "pleaseRequestAccessForView": "Please request for higher permission from the Admin / Base owner / Workspace owner to get access to this {viewName}",
"preventHideAllOptions": "You cannot hide all options if field is required"
}, },
"error": { "error": {
"fetchingCalendarData": "Error fetching calendar data", "fetchingCalendarData": "Error fetching calendar data",

10
packages/nc-gui/lib/types.ts

@ -207,6 +207,15 @@ interface FormFieldsLimitOptionsType {
show: boolean show: boolean
} }
interface ImageCropperConfig {
stencilProps?: {
aspectRatio?: number
}
minHeight?: number
minWidth?: number
imageRestriction?: 'fill-area' | 'fit-area' | 'stencil' | 'none'
}
export type { export type {
User, User,
ProjectMetaInfo, ProjectMetaInfo,
@ -236,4 +245,5 @@ export type {
CommandPaletteType, CommandPaletteType,
CalendarRangeType, CalendarRangeType,
FormFieldsLimitOptionsType, FormFieldsLimitOptionsType,
ImageCropperConfig,
} }

1
packages/nc-gui/package.json

@ -41,6 +41,7 @@
"@iconify/vue": "^4.1.1", "@iconify/vue": "^4.1.1",
"@pinia/nuxt": "^0.5.1", "@pinia/nuxt": "^0.5.1",
"@tiptap/extension-link": "2.2.4", "@tiptap/extension-link": "2.2.4",
"@tiptap/extension-placeholder": "^2.2.4",
"@tiptap/extension-task-list": "2.2.4", "@tiptap/extension-task-list": "2.2.4",
"@tiptap/extension-underline": "^2.2.4", "@tiptap/extension-underline": "^2.2.4",
"@tiptap/html": "2.2.4", "@tiptap/html": "2.2.4",

37
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue

@ -9,11 +9,6 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
// For now dark theme is disabled
// const onClick = () => {
// isDark.value = !isDark.value
// }
onMounted(() => { onMounted(() => {
isDark.value = false isDark.value = false
}) })
@ -39,16 +34,6 @@ router.afterEach((to) => shouldRedirect(to.name as string))
}" }"
> >
<NuxtPage /> <NuxtPage />
<!-- <div
class="color-transition flex items-center justify-center cursor-pointer absolute top-4 md:top-15 right-4 md:right-15 rounded-full p-2 bg-white dark:(bg-slate-600) shadow hover:(ring-1 ring-accent ring-opacity-100)"
@click="onClick"
>
<Transition name="slide-left" duration="250" mode="out-in">
<MaterialSymbolsDarkModeOutline v-if="isDark" />
<MaterialSymbolsLightModeOutline v-else />
</Transition>
</div> -->
</div> </div>
</template> </template>
@ -100,7 +85,7 @@ p {
@apply w-full; @apply w-full;
&:not(.layout-list) { &:not(.layout-list) {
@apply rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500; @apply rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500 overflow-hidden;
& > div { & > div {
@apply !bg-transparent; @apply !bg-transparent;
@ -153,32 +138,26 @@ p {
& > div { & > div {
@apply w-full; @apply w-full;
} }
:deep(textarea) {
@apply !p-2;
&:focus { textarea {
box-shadow: none !important; @apply px-3;
}
} }
} }
&:not(.nc-cell-longtext) { &:not(.nc-cell-longtext) {
@apply p-2; @apply p-2;
} }
textarea {
@apply px-4 py-2 rounded;
&:focus {
box-shadow: none !important;
}
}
&.nc-cell-json { &.nc-cell-json {
@apply h-auto; @apply h-auto;
& > div { & > div {
@apply w-full; @apply w-full;
} }
} }
.ant-picker,
input.nc-cell-field {
@apply !py-0 !px-1;
}
} }
} }

57
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue

@ -79,47 +79,60 @@ const onDecode = async (scannedCodeValue: string) => {
</script> </script>
<template> <template>
<div class="h-full flex flex-col items-center"> <div class="h-full flex flex-col items-center w-full max-w-[max(33%,688px)] mx-auto">
<GeneralFormBanner <GeneralFormBanner
v-if="sharedFormView" v-if="sharedFormView && !parseProp(sharedFormView?.meta).hide_banner"
:banner-image-url="sharedFormView.banner_image_url" :banner-image-url="sharedFormView.banner_image_url"
class="flex-none dark:border-none" class="flex-none dark:border-none"
/> />
<div <div
class="transition-all duration-300 ease-in relative flex flex-col justify-center gap-2 w-full max-w-[max(33%,688px)] mx-auto my-6 bg-white dark:bg-transparent rounded-3xl border-1 border-gray-200 px-4 py-8 lg:p-12 md:(p-8 dark:bg-slate-700)" class="transition-all duration-300 ease-in relative flex flex-col justify-center gap-2 w-full my-6 bg-white dark:bg-transparent rounded-3xl border-1 border-gray-200 px-4 py-8 lg:p-12 md:(p-8 dark:bg-slate-700)"
> >
<template v-if="sharedFormView"> <template v-if="sharedFormView">
<div class="mb-4"> <div>
<h1 class="text-2xl font-bold text-gray-900 mb-4"> <h1 class="text-2xl font-bold text-gray-900 mb-4">
{{ sharedFormView.heading }} {{ sharedFormView.heading }}
</h1> </h1>
<h2 v-if="sharedFormView.subheading" class="font-medium text-base text-gray-500 dark:text-slate-300 mb-4"> <div v-if="sharedFormView.subheading">
{{ sharedFormView.subheading }} <LazyCellRichText
</h2> :value="sharedFormView.subheading"
class="font-medium text-base text-gray-500 dark:text-slate-300 !h-auto mb-4 -ml-1"
is-form-field
read-only
sync-value-change
/>
</div>
</div> </div>
<a-alert v-if="notFound" type="warning" class="my-4 text-center" message="Not found" /> <a-alert v-if="notFound" type="warning" class="!mt-2 !mb-4 text-center" message="Not found" />
<template v-else-if="submitted"> <template v-else-if="submitted">
<div class="flex justify-center"> <div class="flex justify-center">
<div v-if="sharedFormView" class="w-full lg:w-[95%]"> <div v-if="sharedFormView" class="w-full">
<a-alert <a-alert class="!mt-2 !mb-4 !py-4 text-left !rounded-lg" type="success" outlined>
type="success" <template #message>
class="!my-4 text-center !rounded-lg" <LazyCellRichText
outlined v-if="sharedFormView?.success_msg?.trim()"
:message="sharedFormView.success_msg || 'Successfully submitted form data'" :value="sharedFormView?.success_msg"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/> />
<span v-else> {{ $t('msg.successfullySubmittedFormData') }} </span>
</template>
</a-alert>
<p v-if="sharedFormView.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4"> <p v-if="sharedFormView.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 my-4">
{{ $t('msg.newFormWillBeLoaded', { seconds: secondsRemain }) }} {{ $t('msg.newFormWillBeLoaded', { seconds: secondsRemain }) }}
</p> </p>
<div v-if="sharedFormView.submit_another_form" class="text-center"> <div v-if="sharedFormView.submit_another_form" class="text-right">
<NcButton type="primary" size="medium" @click="submitted = false"> <NcButton type="primary" size="medium" @click="submitted = false">
{{ $t('activity.submitAnotherForm') }}</NcButton {{ $t('activity.submitAnotherForm') }}
> </NcButton>
</div> </div>
</div> </div>
</div> </div>
@ -157,7 +170,13 @@ const onDecode = async (scannedCodeValue: string) => {
<span v-if="isRequired(field, field.required)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span> <span v-if="isRequired(field, field.required)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span>
</div> </div>
<div v-if="field?.description" class="nc-form-column-description text-gray-500 text-sm"> <div v-if="field?.description" class="nc-form-column-description text-gray-500 text-sm">
{{ field?.description }} <LazyCellRichText
:value="field?.description"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/>
</div> </div>
<div> <div>

49
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue

@ -239,20 +239,22 @@ onMounted(() => {
<div <div
v-if="sharedFormView" v-if="sharedFormView"
style="height: max(40vh, 225px); min-height: 225px" style="height: max(40vh, 225px); min-height: 225px"
class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end" class="w-full max-w-[max(33%,600px)] mx-auto flex flex-col justify-end"
> >
<div class="px-4 md:px-0 flex flex-col justify-end"> <div class="px-4 md:px-0 flex flex-col justify-end">
<h1 class="text-2xl font-bold text-gray-900 self-center my-4" data-testid="nc-survey-form__heading"> <h1 class="text-2xl font-bold text-gray-900 self-center text-center my-4" data-testid="nc-survey-form__heading">
{{ sharedFormView.heading }} {{ sharedFormView.heading }}
</h1> </h1>
<div v-if="sharedFormView.subheading?.trim()" class="w-full">
<h2 <LazyCellRichText
v-if="sharedFormView.subheading && sharedFormView.subheading !== ''" :value="sharedFormView.subheading"
class="font-medium text-base text-gray-500 dark:text-gray-300 self-center mb-4" class="font-medium text-base text-gray-500 dark:text-slate-300 !h-auto mb-4 -ml-1"
is-form-field
read-only
sync-value-change
data-testid="nc-survey-form__sub-heading" data-testid="nc-survey-form__sub-heading"
> />
{{ sharedFormView?.subheading }} </div>
</h2>
</div> </div>
</div> </div>
@ -275,7 +277,7 @@ onMounted(() => {
class="nc-form-column-description text-gray-500 text-sm" class="nc-form-column-description text-gray-500 text-sm"
data-testid="nc-survey-form__field-description" data-testid="nc-survey-form__field-description"
> >
{{ field?.description }} <LazyCellRichText :value="field?.description" class="!h-auto -ml-1" is-form-field read-only sync-value-change />
</div> </div>
<LazySmartsheetDivDataCell v-if="field.title" class="relative nc-form-data-cell"> <LazySmartsheetDivDataCell v-if="field.title" class="relative nc-form-data-cell">
@ -374,20 +376,35 @@ onMounted(() => {
<Transition name="slide-left"> <Transition name="slide-left">
<div v-if="submitted" class="flex flex-col justify-center items-center text-center"> <div v-if="submitted" class="flex flex-col justify-center items-center text-center">
<a-alert <a-alert
class="!my-4 !py-4 !rounded-lg text-left w-full"
type="success" type="success"
class="!my-4 !py-4 text-center !rounded-lg"
data-testid="nc-survey-form__success-msg" data-testid="nc-survey-form__success-msg"
outlined outlined
:message="sharedFormView?.success_msg || $t('msg.info.thankYou')" >
:description="sharedFormView?.success_msg ? undefined : $t('msg.info.submittedFormData')" <template #message>
<LazyCellRichText
v-if="sharedFormView?.success_msg?.trim()"
:value="sharedFormView?.success_msg"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/> />
<span v-else>
{{ $t('msg.info.thankYou') }}
</span>
</template>
<template v-if="!sharedFormView?.success_msg?.trim()" #description>
{{ $t('msg.info.submittedFormData') }}
</template>
</a-alert>
<div v-if="sharedFormView" class="mt-3"> <div v-if="sharedFormView" class="mt-3 w-full">
<p v-if="sharedFormView?.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4"> <p v-if="sharedFormView?.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-left my-4">
{{ $t('labels.newFormLoaded') }} {{ secondsRemain }} {{ $t('general.seconds') }} {{ $t('labels.newFormLoaded') }} {{ secondsRemain }} {{ $t('general.seconds') }}
</p> </p>
<div v-if="sharedFormView?.submit_another_form" class="text-center"> <div v-if="sharedFormView?.submit_another_form" class="text-right">
<NcButton type="primary" size="medium" data-testid="nc-survey-form__btn-submit-another-form" @click="resetForm"> <NcButton type="primary" size="medium" data-testid="nc-survey-form__btn-submit-another-form" @click="resetForm">
{{ $t('activity.submitAnotherForm') }} {{ $t('activity.submitAnotherForm') }}
</NcButton> </NcButton>

2
packages/nc-gui/utils/iconUtils.ts

@ -128,6 +128,7 @@ import NcItalic from '~icons/nc-icons/italic'
import NcBold from '~icons/nc-icons/bold' import NcBold from '~icons/nc-icons/bold'
import NcUnderline from '~icons/nc-icons/underline' import NcUnderline from '~icons/nc-icons/underline'
import NcCrop from '~icons/nc-icons/crop' import NcCrop from '~icons/nc-icons/crop'
import NcLink from '~icons/nc-icons/link'
// keep it for reference // keep it for reference
// todo: remove it after all icons are migrated // todo: remove it after all icons are migrated
@ -326,6 +327,7 @@ export const iconMap = {
translate: h('span', { class: 'material-symbols' }, 'translate'), translate: h('span', { class: 'material-symbols' }, 'translate'),
preview: h('span', { class: 'material-symbols' }, 'visibility'), preview: h('span', { class: 'material-symbols' }, 'visibility'),
link: h('span', { class: 'material-symbols' }, 'link'), link: h('span', { class: 'material-symbols' }, 'link'),
link2: NcLink,
returnKey: h('span', { class: 'material-symbols' }, 'keyboard_return'), returnKey: h('span', { class: 'material-symbols' }, 'keyboard_return'),
keyboard: h('span', { class: 'material-symbols' }, 'keyboard'), keyboard: h('span', { class: 'material-symbols' }, 'keyboard'),
accountPlus: h('span', { class: 'material-symbols' }, 'person_add'), accountPlus: h('span', { class: 'material-symbols' }, 'person_add'),

13
pnpm-lock.yaml

@ -42,6 +42,9 @@ importers:
'@tiptap/extension-link': '@tiptap/extension-link':
specifier: 2.2.4 specifier: 2.2.4
version: 2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4) version: 2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4)
'@tiptap/extension-placeholder':
specifier: ^2.2.4
version: 2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4)
'@tiptap/extension-task-list': '@tiptap/extension-task-list':
specifier: 2.2.4 specifier: 2.2.4
version: 2.2.4(@tiptap/core@2.2.4) version: 2.2.4(@tiptap/core@2.2.4)
@ -8742,6 +8745,16 @@ packages:
'@tiptap/core': 2.2.4(@tiptap/pm@2.2.4) '@tiptap/core': 2.2.4(@tiptap/pm@2.2.4)
dev: false dev: false
/@tiptap/extension-placeholder@2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4):
resolution: {integrity: sha512-UL4Fn9T33SoS7vdI3NnSxBJVeGUIgCIutgXZZ5J8CkcRoDIeS78z492z+6J+qGctHwTd0xUL5NzNJI82HfiTdg==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
dependencies:
'@tiptap/core': 2.2.4(@tiptap/pm@2.2.4)
'@tiptap/pm': 2.2.4
dev: false
/@tiptap/extension-strike@2.2.4(@tiptap/core@2.2.4): /@tiptap/extension-strike@2.2.4(@tiptap/core@2.2.4):
resolution: {integrity: sha512-/a2EwQgA+PpG17V2tVRspcrIY0SN3blwcgM7lxdW4aucGkqSKnf7+91dkhQEwCZ//o8kv9mBCyRoCUcGy6S5Xg==} resolution: {integrity: sha512-/a2EwQgA+PpG17V2tVRspcrIY0SN3blwcgM7lxdW4aucGkqSKnf7+91dkhQEwCZ//o8kv9mBCyRoCUcGy6S5Xg==}
peerDependencies: peerDependencies:

32
tests/playwright/pages/Dashboard/Form/index.ts

@ -10,8 +10,7 @@ export class FormPage extends BasePage {
readonly topbar: TopbarPage; readonly topbar: TopbarPage;
// todo: All the locator should be private // todo: All the locator should be private
readonly addAllButton: Locator; readonly addOrRemoveAllButton: Locator;
readonly removeAllButton: Locator;
readonly submitButton: Locator; readonly submitButton: Locator;
readonly showAnotherFormRadioButton: Locator; readonly showAnotherFormRadioButton: Locator;
@ -30,8 +29,10 @@ export class FormPage extends BasePage {
this.toolbar = new ToolbarPage(this); this.toolbar = new ToolbarPage(this);
this.topbar = new TopbarPage(this); this.topbar = new TopbarPage(this);
this.addAllButton = dashboard.get().locator('[data-testid="nc-form-show-all-fields"]').locator('.nc-switch'); this.addOrRemoveAllButton = dashboard
this.removeAllButton = dashboard.get().locator('[data-testid="nc-form-show-all-fields"]').locator('.nc-switch'); .get()
.locator('[data-testid="nc-form-show-all-fields"]')
.locator('.nc-switch');
this.submitButton = dashboard.get().locator('[data-testid="nc-form-submit"]'); this.submitButton = dashboard.get().locator('[data-testid="nc-form-submit"]');
this.showAnotherFormRadioButton = dashboard.get().locator('[data-testid="nc-form-checkbox-submit-another-form"]'); this.showAnotherFormRadioButton = dashboard.get().locator('[data-testid="nc-form-checkbox-submit-another-form"]');
@ -40,8 +41,8 @@ export class FormPage extends BasePage {
.locator('[data-testid="nc-form-checkbox-show-blank-form"]'); .locator('[data-testid="nc-form-checkbox-show-blank-form"]');
this.emailMeRadioButton = dashboard.get().locator('[data-testid="nc-form-checkbox-send-email"]'); this.emailMeRadioButton = dashboard.get().locator('[data-testid="nc-form-checkbox-send-email"]');
this.formHeading = dashboard.get().locator('[data-testid="nc-form-heading"]'); this.formHeading = dashboard.get().locator('[data-testid="nc-form-heading"]');
this.formSubHeading = dashboard.get().locator('[data-testid="nc-form-sub-heading"]'); this.formSubHeading = dashboard.get().locator('[data-testid="nc-form-sub-heading"] .tiptap.ProseMirror');
this.afterSubmitMsg = dashboard.get().locator('[data-testid="nc-form-after-submit-msg"]'); this.afterSubmitMsg = dashboard.get().locator('[data-testid="nc-form-after-submit-msg"] .tiptap.ProseMirror');
this.formFields = dashboard.get().locator('.nc-form-fields-list'); this.formFields = dashboard.get().locator('.nc-form-fields-list');
} }
@ -67,7 +68,7 @@ export class FormPage extends BasePage {
} }
getFormFieldsInputHelpText() { getFormFieldsInputHelpText() {
return this.get().locator('textarea[data-testid="nc-form-input-help-text"]:visible'); return this.get().locator('[data-testid="nc-form-input-help-text"] .tiptap.ProseMirror:visible');
} }
async verifyFormFieldLabel({ index, label }: { index: number; label: string }) { async verifyFormFieldLabel({ index, label }: { index: number; label: string }) {
@ -83,7 +84,7 @@ export class FormPage extends BasePage {
} }
async verifyAfterSubmitMsg({ msg }: { msg: string }) { async verifyAfterSubmitMsg({ msg }: { msg: string }) {
expect((await this.afterSubmitMsg.inputValue()).includes(msg)).toBeTruthy(); expect((await this.afterSubmitMsg.textContent()).includes(msg)).toBeTruthy();
} }
async verifyFormViewFieldsOrder({ fields }: { fields: string[] }) { async verifyFormViewFieldsOrder({ fields }: { fields: string[] }) {
@ -136,11 +137,18 @@ export class FormPage extends BasePage {
} }
async removeAllFields() { async removeAllFields() {
await this.removeAllButton.click(); if (await this.addOrRemoveAllButton.isChecked()) {
await this.addOrRemoveAllButton.click();
} else {
await this.addOrRemoveAllButton.click();
await this.addOrRemoveAllButton.click();
}
} }
async addAllFields() { async addAllFields() {
await this.addAllButton.click(); if (!(await this.addOrRemoveAllButton.isChecked())) {
await this.addOrRemoveAllButton.click();
}
} }
async configureHeader(param: { subtitle: string; title: string }) { async configureHeader(param: { subtitle: string; title: string }) {
@ -166,7 +174,7 @@ export class FormPage extends BasePage {
async verifyHeader(param: { subtitle: string; title: string }) { async verifyHeader(param: { subtitle: string; title: string }) {
await expect.poll(async () => await this.formHeading.inputValue()).toBe(param.title); await expect.poll(async () => await this.formHeading.inputValue()).toBe(param.title);
await expect.poll(async () => await this.formSubHeading.inputValue()).toBe(param.subtitle); await expect.poll(async () => await this.formSubHeading.textContent()).toBe(param.subtitle);
} }
async fillForm(param: { field: string; value: string }[]) { async fillForm(param: { field: string; value: string }[]) {
@ -233,7 +241,7 @@ export class FormPage extends BasePage {
const fieldHelpText = this.get() const fieldHelpText = this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`) .locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('div[data-testid="nc-form-input-help-text-label"]'); .locator('div[data-testid="nc-form-input-help-text-label"] .tiptap.ProseMirror');
await expect(fieldHelpText).toHaveText(helpText); await expect(fieldHelpText).toHaveText(helpText);
} }

1
tests/playwright/tests/db/views/viewForm.spec.ts

@ -6,7 +6,6 @@ import { SharedFormPage } from '../../../pages/SharedForm';
import { Api, UITypes } from 'nocodb-sdk'; import { Api, UITypes } from 'nocodb-sdk';
import { LoginPage } from '../../../pages/LoginPage'; import { LoginPage } from '../../../pages/LoginPage';
import { getDefaultPwd } from '../../../tests/utils/general'; import { getDefaultPwd } from '../../../tests/utils/general';
import { WorkspacePage } from '../../../pages/WorkspacePage';
import { enableQuickRun, isEE } from '../../../setup/db'; import { enableQuickRun, isEE } from '../../../setup/db';
// todo: Move most of the ui actions to page object and await on the api response // todo: Move most of the ui actions to page object and await on the api response

Loading…
Cancel
Save