Browse Source

Merge pull request #3669 from nocodb/feat/survey-form

feat(nc-gui): add survey mode option to share view
pull/3975/head
Raju Udava 2 years ago committed by GitHub
parent
commit
a6a6ebaa93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/nc-gui/assets/style.scss
  2. 4
      packages/nc-gui/components.d.ts
  3. 6
      packages/nc-gui/components/cell/Url.vue
  4. 2
      packages/nc-gui/components/cell/attachment/Carousel.vue
  5. 104
      packages/nc-gui/components/cell/attachment/index.vue
  6. 51
      packages/nc-gui/components/cell/attachment/utils.ts
  7. 3
      packages/nc-gui/components/general/NocoIcon.vue
  8. 17
      packages/nc-gui/components/general/PoweredBy.vue
  9. 18
      packages/nc-gui/components/smartsheet/Cell.vue
  10. 339
      packages/nc-gui/components/smartsheet/Form.vue
  11. 293
      packages/nc-gui/components/smartsheet/Grid.vue
  12. 10
      packages/nc-gui/components/smartsheet/Toolbar.vue
  13. 14
      packages/nc-gui/components/smartsheet/VirtualCell.vue
  14. 5
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  15. 2
      packages/nc-gui/components/smartsheet/toolbar/AddRow.vue
  16. 22
      packages/nc-gui/components/smartsheet/toolbar/Reload.vue
  17. 172
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  18. 2
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  19. 22
      packages/nc-gui/composables/useSharedFormViewStore.ts
  20. 6
      packages/nc-gui/composables/useTheme/index.ts
  21. 1
      packages/nc-gui/context/index.ts
  22. 17
      packages/nc-gui/lib/types.ts
  23. 4
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  24. 84
      packages/nc-gui/pages/[projectType]/form/[viewId].vue
  25. 233
      packages/nc-gui/pages/[projectType]/form/[viewId]/index.vue
  26. 125
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue
  27. 346
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue
  28. 4
      packages/nc-gui/pages/error/404.vue
  29. 8
      packages/nc-gui/pages/index/index.vue
  30. 38
      packages/nc-gui/pages/index/index/index.vue
  31. 36
      scripts/cypress/integration/common/4b_table_view_share.js
  32. 6
      scripts/cypress/support/commands.js

4
packages/nc-gui/assets/style.scss

@ -271,3 +271,7 @@ a {
transform: scale(75%); transform: scale(75%);
transform-origin: bottom right; transform-origin: bottom right;
} }
.nc-toolbar-btn {
@apply !shadow-none rounded hover:(ring-1 ring-primary ring-opacity-100) focus:(ring-1 ring-accent ring-opacity-100);
}

4
packages/nc-gui/components.d.ts vendored

@ -100,7 +100,10 @@ declare module '@vue/runtime-core' {
MaterialSymbolsAttachFile: typeof import('~icons/material-symbols/attach-file')['default'] MaterialSymbolsAttachFile: typeof import('~icons/material-symbols/attach-file')['default']
MaterialSymbolsChevronRightRounded: typeof import('~icons/material-symbols/chevron-right-rounded')['default'] MaterialSymbolsChevronRightRounded: typeof import('~icons/material-symbols/chevron-right-rounded')['default']
MaterialSymbolsCloseRounded: typeof import('~icons/material-symbols/close-rounded')['default'] MaterialSymbolsCloseRounded: typeof import('~icons/material-symbols/close-rounded')['default']
MaterialSymbolsDarkModeOutline: typeof import('~icons/material-symbols/dark-mode-outline')['default']
MaterialSymbolsFileCopyOutline: typeof import('~icons/material-symbols/file-copy-outline')['default'] MaterialSymbolsFileCopyOutline: typeof import('~icons/material-symbols/file-copy-outline')['default']
MaterialSymbolsKeyboardReturn: typeof import('~icons/material-symbols/keyboard-return')['default']
MaterialSymbolsLightModeOutline: typeof import('~icons/material-symbols/light-mode-outline')['default']
MaterialSymbolsRocketLaunchOutline: typeof import('~icons/material-symbols/rocket-launch-outline')['default'] MaterialSymbolsRocketLaunchOutline: typeof import('~icons/material-symbols/rocket-launch-outline')['default']
MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default'] MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default'] MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default']
@ -129,6 +132,7 @@ declare module '@vue/runtime-core' {
MdiCheck: typeof import('~icons/mdi/check')['default'] MdiCheck: typeof import('~icons/mdi/check')['default']
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default'] MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiClose: typeof import('~icons/mdi/close')['default'] MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default'] MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default'] MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']

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

@ -28,9 +28,9 @@ const column = inject(ColumnInj)!
const editEnabled = inject(EditModeInj)! const editEnabled = inject(EditModeInj)!
const disableOverlay = inject(CellUrlDisableOverlayInj) const disableOverlay = inject(CellUrlDisableOverlayInj, ref(false))
// Used in the logic of when to display error since we are not storing the url if its not valid // Used in the logic of when to display error since we are not storing the url if it's not valid
const localState = ref(value) const localState = ref(value)
const vModel = computed({ const vModel = computed({
@ -72,7 +72,7 @@ watch(
</script> </script>
<template> <template>
<div class="flex flex-row items-center justify-between"> <div class="flex flex-row items-center justify-between w-full h-full">
<input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none text-sm w-full" @blur="editEnabled = false" /> <input v-if="editEnabled" :ref="focus" v-model="vModel" class="outline-none text-sm w-full" @blur="editEnabled = false" />
<nuxt-link <nuxt-link

2
packages/nc-gui/components/cell/attachment/Carousel.vue

@ -55,7 +55,7 @@ onClickOutside(carouselRef, () => {
</div> </div>
<div <div
class="select-none group hover:ring active:ring-accent ring-opactiy-100 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow" class="select-none group hover:(ring-1 ring-accent) ring-opacity-100 cursor-pointer leading-8 inline-block px-3 py-1 bg-gray-300 text-white mb-4 text-center rounded shadow"
@click.stop="downloadFile(selectedImage)" @click.stop="downloadFile(selectedImage)"
> >
<h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3> <h3 class="group-hover:text-primary">{{ selectedImage && selectedImage.title }}</h3>

104
packages/nc-gui/components/cell/attachment/index.vue

@ -3,7 +3,7 @@ import { onKeyDown } from '@vueuse/core'
import { useProvideAttachmentCell } from './utils' import { useProvideAttachmentCell } from './utils'
import { useSortable } from './sort' import { useSortable } from './sort'
import { import {
IsFormInj, DropZoneRef,
IsGalleryInj, IsGalleryInj,
inject, inject,
isImage, isImage,
@ -22,24 +22,28 @@ interface Props {
} }
interface Emits { interface Emits {
(event: 'update:modelValue', value: string | Record<string, any>): void (event: 'update:modelValue', value: string | Record<string, any>[]): void
} }
const { modelValue, rowIndex } = defineProps<Props>() const { modelValue, rowIndex } = defineProps<Props>()
const emits = defineEmits<Emits>() const emits = defineEmits<Emits>()
const isForm = inject(IsFormInj, ref(false))
const isGallery = inject(IsGalleryInj, ref(false)) const isGallery = inject(IsGalleryInj, ref(false))
const dropZoneInjection = inject(DropZoneRef, ref())
const attachmentCellRef = ref<HTMLDivElement>() const attachmentCellRef = ref<HTMLDivElement>()
const sortableRef = ref<HTMLDivElement>() const sortableRef = ref<HTMLDivElement>()
const { cellRefs } = useSmartsheetStoreOrThrow()! const currentCellRef = ref<Element | undefined>(dropZoneInjection.value)
const { cellRefs, isSharedForm } = useSmartsheetStoreOrThrow()!
const { const {
isPublic,
isForm,
column, column,
modalVisible, modalVisible,
attachments, attachments,
@ -53,12 +57,12 @@ const {
storedFiles, storedFiles,
} = useProvideAttachmentCell(updateModelValue) } = useProvideAttachmentCell(updateModelValue)
const currentCellRef = ref()
watch( watch(
[() => rowIndex, isForm], [() => rowIndex, isForm, attachmentCellRef],
() => { () => {
if (!rowIndex && isForm.value && isGallery.value) { if (dropZoneInjection?.value) return
if (!rowIndex && (isForm.value || isGallery.value)) {
currentCellRef.value = attachmentCellRef.value currentCellRef.value = attachmentCellRef.value
} else { } else {
nextTick(() => { nextTick(() => {
@ -83,7 +87,7 @@ const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, is
const { state: rowState } = useSmartsheetRowStoreOrThrow() const { state: rowState } = useSmartsheetRowStoreOrThrow()
const { isOverDropZone } = useDropZone(currentCellRef, onDrop) const { isOverDropZone } = useDropZone(currentCellRef as any, onDrop)
/** on new value, reparse our stored attachments */ /** on new value, reparse our stored attachments */
watch( watch(
@ -91,10 +95,20 @@ watch(
(nextModel) => { (nextModel) => {
if (nextModel) { if (nextModel) {
try { try {
attachments.value = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean) const nextAttachments = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean)
if (isPublic.value && isForm.value) {
storedFiles.value = nextAttachments
} else {
attachments.value = nextAttachments
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
attachments.value = [] if (isPublic.value && isForm.value) {
storedFiles.value = []
} else {
attachments.value = []
}
} }
} }
}, },
@ -102,8 +116,8 @@ watch(
) )
/** updates attachments array for autosave */ /** updates attachments array for autosave */
function updateModelValue(data: string | Record<string, any>) { function updateModelValue(data: string | Record<string, any>[]) {
emits('update:modelValue', typeof data !== 'string' ? JSON.stringify(data) : data) emits('update:modelValue', data)
} }
/** Close modal on escape press, disable dropzone as well */ /** Close modal on escape press, disable dropzone as well */
@ -119,8 +133,6 @@ watch(
rowState.value[column.value!.title!] = storedFiles.value rowState.value[column.value!.title!] = storedFiles.value
}, },
) )
const { isSharedForm } = useSmartsheetStoreOrThrow()
</script> </script>
<template> <template>
@ -135,7 +147,7 @@ const { isSharedForm } = useSmartsheetStoreOrThrow()
v-model="isOverDropZone" v-model="isOverDropZone"
inline inline
:target="currentCellRef" :target="currentCellRef"
class="group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)" class="nc-attachment-cell-dropzone text-white text-lg ring ring-accent ring-opacity-100 bg-gray-700/75 flex items-center justify-center gap-2 backdrop-blur-xl"
> >
<MaterialSymbolsFileCopyOutline class="text-accent" /> Drop here <MaterialSymbolsFileCopyOutline class="text-accent" /> Drop here
</general-overlay> </general-overlay>
@ -144,7 +156,7 @@ const { isSharedForm } = useSmartsheetStoreOrThrow()
<div <div
v-if="!isReadonly" v-if="!isReadonly"
:class="{ 'mx-auto px-4': !visibleItems.length }" :class="{ 'mx-auto px-4': !visibleItems.length }"
class="group flex gap-1 items-center active:ring rounded border-1 p-1 hover:(bg-primary bg-opacity-10)" class="group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
@click.stop="open" @click.stop="open"
> >
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> <MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
@ -153,56 +165,66 @@ const { isSharedForm } = useSmartsheetStoreOrThrow()
<template #title> Click or drop a file into cell </template> <template #title> Click or drop a file into cell </template>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<MaterialSymbolsAttachFile class="transform group-hover:(text-accent scale-120) text-gray-500 text-[10px]" /> <MaterialSymbolsAttachFile
class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-[0.75rem]"
/>
<div v-if="!visibleItems.length" class="group-hover:text-primary text-gray-500 text-xs">Add file(s)</div> <div
v-if="!visibleItems.length"
class="group-hover:text-primary text-gray-500 dark:text-gray-200 dark:group-hover:!text-white text-xs"
>
Add file(s)
</div>
</div> </div>
</a-tooltip> </a-tooltip>
</div> </div>
<div v-else class="flex" /> <div v-else class="flex" />
<template v-if="visibleItems.length"> <template v-if="visibleItems.length">
<div <div
ref="sortableRef" ref="sortableRef"
:class="{ dragging }" :class="{ dragging }"
class="flex justify-center items-center flex-wrap gap-2 p-1 scrollbar-thin-dull max-h-[150px] overflow-auto" class="flex cursor-pointer justify-center items-center flex-wrap gap-2 p-1 scrollbar-thin-dull max-h-[150px] overflow-auto"
> >
<div <template v-for="(item, i) of visibleItems" :key="item.url || item.title">
v-for="(item, i) of visibleItems"
:key="item.url || item.title"
:class="isImage(item.title, item.mimetype ?? item.type) ? '' : 'border-1 rounded'"
class="nc-attachment flex items-center justify-center min-h-[50px]"
>
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> <template #title>
<div class="text-center w-full">{{ item.title }}</div> <div class="text-center w-full">{{ item.title }}</div>
</template> </template>
<LazyNuxtImg <template v-if="isImage(item.title, item.mimetype ?? item.type) && (item.url || item.data)">
v-if="isImage(item.title, item.mimetype ?? item.type) && (item.url || item.data)" <div class="nc-attachment flex items-center justify-center" @click="selectedImage = item">
quality="75" <LazyNuxtImg
placeholder quality="75"
:alt="item.title || `#${i}`" placeholder
:src="item.url || item.data" fit="cover"
class="ring-1 ring-gray-300 rounded max-h-[50px] max-w-[50px]" :alt="item.title || `#${i}`"
@click="selectedImage = item" :src="item.url || item.data"
/> class="max-w-full max-h-full"
/>
</div>
</template>
<component :is="FileIcon(item.icon)" v-else-if="item.icon" @click="openLink(item.url || item.data)" /> <div v-else class="nc-attachment flex items-center justify-center" @click="openLink(item.url || item.data)">
<component :is="FileIcon(item.icon)" v-if="item.icon" />
<IcOutlineInsertDriveFile v-else @click.stop="openLink(item.url || item.data)" /> <IcOutlineInsertDriveFile v-else />
</div>
</a-tooltip> </a-tooltip>
</div> </template>
</div> </div>
<div class="group flex gap-1 items-center border-1 active:ring rounded p-1 hover:(bg-primary bg-opacity-10)"> <div
class="group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-1 p-1 shadow-sm hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
>
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> <MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
<a-tooltip v-else placement="bottom"> <a-tooltip v-else placement="bottom">
<template #title> View attachments </template> <template #title> View attachments </template>
<MdiArrowExpand <MdiArrowExpand
class="select-none transform group-hover:(text-accent scale-120) text-[10px] text-gray-500" class="transform dark:(!text-white) group-hover:(!text-accent scale-120) text-gray-500 text-[0.75rem]"
@click.stop="modalVisible = true" @click.stop="modalVisible = true"
/> />
</a-tooltip> </a-tooltip>

51
packages/nc-gui/components/cell/attachment/utils.ts

@ -79,37 +79,37 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
async function onFileSelect(selectedFiles: FileList | File[]) { async function onFileSelect(selectedFiles: FileList | File[]) {
if (!selectedFiles.length) return if (!selectedFiles.length) return
if (isPublic.value) { if (isPublic.value && isForm.value) {
storedFiles.value.push( const newFiles = await Promise.all<AttachmentProps>(
...(await Promise.all<AttachmentProps>( Array.from(selectedFiles).map(
Array.from(selectedFiles).map( (file) =>
(file) => new Promise<AttachmentProps>((resolve) => {
new Promise<AttachmentProps>((resolve) => { const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type }
const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type }
if (isImage(file.name, (<any>file).mimetype ?? file.type)) {
const reader = new FileReader()
reader.onload = (e) => { if (isImage(file.name, (<any>file).mimetype ?? file.type)) {
res.data = e.target?.result const reader = new FileReader()
resolve(res) reader.onload = (e) => {
} res.data = e.target?.result
reader.onerror = () => { resolve(res)
resolve(res) }
}
reader.readAsDataURL(file) reader.onerror = () => {
} else {
resolve(res) resolve(res)
} }
}),
), reader.readAsDataURL(file)
)), } else {
resolve(res)
}
}),
),
) )
return updateModelValue(storedFiles.value.map((stored) => stored.file)) attachments.value = [...attachments.value, ...newFiles]
return updateModelValue(attachments.value)
} }
const newAttachments = [] const newAttachments = []
@ -132,7 +132,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
} }
} }
updateModelValue([...attachments.value, ...newAttachments]) updateModelValue(JSON.stringify([...attachments.value, ...newAttachments]))
} }
/** save files on drop */ /** save files on drop */
@ -163,7 +163,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
} }
} }
/** our currently visible items, either the locally stored or the ones from db, depending on isPublicForm status */ /** our currently visible items, either the locally stored or the ones from db, depending on isPublic & isForm status */
const visibleItems = computed<any[]>(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value)) const visibleItems = computed<any[]>(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value))
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles)) watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles))
@ -172,6 +172,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
attachments, attachments,
visibleItems, visibleItems,
isPublic, isPublic,
isForm,
isReadonly, isReadonly,
meta, meta,
column, column,

3
packages/nc-gui/components/general/NocoIcon.vue

@ -11,7 +11,8 @@ const { width = 90, height = 90, animate = false } = defineProps<Props>()
<template> <template>
<div :style="{ left: `calc(50% - ${width / 2}px)`, top: `-${height / 2}px` }" class="absolute rounded-lg pt-1 pl-1 -ml-1"> <div :style="{ left: `calc(50% - ${width / 2}px)`, top: `-${height / 2}px` }" class="absolute rounded-lg pt-1 pl-1 -ml-1">
<div class="relative"> <div class="relative">
<img :width="width" :height="height" alt="NocoDB" src="~/assets/img/icons/512x512.png" /> <img class="hidden dark:block" :width="width" :height="height" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
<img class="dark:hidden" :width="width" :height="height" alt="NocoDB" src="~/assets/img/icons/512x512.png" />
<template v-if="animate"> <template v-if="animate">
<div class="animated-bg-gradient opacity-100 rounded-full z-0 absolute bottom-1.45 right-1.45 h-4.5 w-4.5" /> <div class="animated-bg-gradient opacity-100 rounded-full z-0 absolute bottom-1.45 right-1.45 h-4.5 w-4.5" />

17
packages/nc-gui/components/general/PoweredBy.vue

@ -0,0 +1,17 @@
<script lang="ts" setup>
import { openLink } from '#imports'
</script>
<template>
<button
type="button"
class="cursor-pointer group text-xs text-slate-500 hover:text-primary dark:hover:text-white dark:text-slate-300 mx-auto my-4 flex justify-center gap-1 items-center"
@click="openLink('https://github.com/nocodb/nocodb')"
>
<span class="relative rounded">
<GeneralNocoIcon v-bind="$attrs" class="!relative !top-0" :width="32" :height="32" />
</span>
<span>Powered by NocoDB</span>
</button>
</template>

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

@ -38,8 +38,6 @@ const column = toRef(props, 'column')
const active = toRef(props, 'active', false) const active = toRef(props, 'active', false)
const virtual = toRef(props, 'virtual', false)
const readOnly = toRef(props, 'readOnly', undefined) const readOnly = toRef(props, 'readOnly', undefined)
provide(ColumnInj, column) provide(ColumnInj, column)
@ -125,7 +123,7 @@ const {
isPhoneNumber, isPhoneNumber,
} = useColumn(column) } = useColumn(column)
const syncAndNavigate = (dir: NavigateDir) => { const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
if (isJSON.value) return if (isJSON.value) return
if (currentRow.value.rowMeta.changed) { if (currentRow.value.rowMeta.changed) {
@ -133,19 +131,17 @@ const syncAndNavigate = (dir: NavigateDir) => {
currentRow.value.rowMeta.changed = false currentRow.value.rowMeta.changed = false
} }
emit('navigate', dir) emit('navigate', dir)
if (!isForm.value) e.stopImmediatePropagation()
} }
</script> </script>
<template> <template>
<div <div
class="nc-cell w-full h-full" class="nc-cell w-full"
:class="{ 'text-blue-600': isPrimary && !virtual && !isForm }" :class="[`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, { 'text-blue-600': isPrimary && !virtual && !isForm }]"
@keydown.stop.left @keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.stop.right @keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
@keydown.stop.up
@keydown.stop.down
@keydown.stop.enter.exact="syncAndNavigate(NavigateDir.NEXT)"
@keydown.stop.shift.enter.exact="syncAndNavigate(NavigateDir.PREV)"
> >
<LazyCellTextArea v-if="isTextArea" v-model="vModel" /> <LazyCellTextArea v-if="isTextArea" v-model="vModel" />
<LazyCellCheckbox v-else-if="isBoolean" v-model="vModel" /> <LazyCellCheckbox v-else-if="isBoolean" v-model="vModel" />

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

@ -97,20 +97,24 @@ const activeRow = ref('')
const { t } = useI18n() const { t } = useI18n()
function updateView() { const updateView = useDebounceFn(
if ((formViewData.value?.subheading?.length || 0) > 255) { () => {
message.error(t('msg.error.formDescriptionTooLong')) if ((formViewData.value?.subheading?.length || 0) > 255) {
return return message.error(t('msg.error.formDescriptionTooLong'))
} }
updateFormView(formViewData.value)
} updateFormView(formViewData.value)
},
300,
{ maxWait: 2000 },
)
async function submitForm() { async function submitForm() {
try { try {
await formRef.value?.validateFields() await formRef.value?.validateFields()
} catch (e: any) { } catch (e: any) {
e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(','))) e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(',')))
return if (e.errorFields.length) return
} }
const insertedRowData = await insertRow(formState) const insertedRowData = await insertRow(formState)
@ -392,104 +396,106 @@ watch(view, (nextView) => {
<div class="text-center">{{ formViewData.success_msg || 'Successfully submitted form data' }}</div> <div class="text-center">{{ formViewData.success_msg || 'Successfully submitted form data' }}</div>
</template> </template>
</a-alert> </a-alert>
<div v-if="formViewData.show_blank_form" class="text-gray-400 mt-4"> <div v-if="formViewData.show_blank_form" class="text-gray-400 mt-4">
New form will be loaded after {{ secondsRemain }} seconds New form will be loaded after {{ secondsRemain }} seconds
</div> </div>
<div v-if="formViewData.submit_another_form" class="text-center mt-4"> <div v-if="formViewData.submit_another_form" class="text-center mt-4">
<a-button type="primary" size="large" @click="submitted = false"> Submit Another Form</a-button> <a-button type="primary" size="large" @click="submitted = false"> Submit Another Form</a-button>
</div> </div>
</div> </div>
</a-col> </a-col>
</a-row> </a-row>
<a-row v-else class="h-full flex"> <a-row v-else class="h-full flex">
<a-col <a-col v-if="isEditable" :span="8" class="shadow p-2 md:p-4 h-full overflow-auto scrollbar-thin-dull nc-form-left-drawer">
v-if="isEditable" <div class="flex flex-wrap gap-2">
:span="8" <div class="flex-1 text-lg">
class="bg-[#f7f7f7] shadow-md p-5 h-full overflow-auto scrollbar-thin-primary nc-form-left-drawer" {{ $t('objects.fields') }}
>
<div class="flex">
<div class="flex flex-row flex-1 text-lg">
<span>
<!-- Fields -->
{{ $t('objects.fields') }}
</span>
</div> </div>
<div class="flex flex-row">
<div class="cursor-pointer mr-2"> <div class="flex flex-wrap gap-2 mb-4">
<span <button
v-if="hiddenColumns.length" v-if="hiddenColumns.length"
class="mr-2 nc-form-add-all" type="button"
style="border-bottom: 2px solid rgb(218, 218, 218)" class="nc-form-add-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
@click="addAllColumns" @click="addAllColumns"
> >
<!-- Add all --> <!-- Add all -->
{{ $t('general.addAll') }} {{ $t('general.addAll') }}
</span> </button>
<span
v-if="localColumns.length" <button
class="ml-2 nc-form-remove-all" v-if="localColumns.length"
style="border-bottom: 2px solid rgb(218, 218, 218)" type="button"
@click="removeAllColumns" class="nc-form-remove-all color-transition bg-white transform hover:(text-primary ring ring-accent ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
> @click="removeAllColumns"
<!-- Remove all --> >
{{ $t('general.removeAll') }} <!-- Remove all -->
</span> {{ $t('general.removeAll') }}
</div> </button>
</div> </div>
</div> </div>
<Draggable <Draggable
:list="hiddenColumns" :list="hiddenColumns"
item-key="id" item-key="id"
draggable=".item" draggable=".item"
group="form-inputs" group="form-inputs"
class="flex flex-col gap-2"
@start="drag = true" @start="drag = true"
@end="drag = false" @end="drag = false"
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<a-card <a-card
size="small" size="small"
class="m-0 p-0 cursor-pointer item mb-2" class="!border-0 color-transition cursor-pointer item hover:(bg-primary ring-1 ring-accent ring-opacity-100) bg-opacity-10 !rounded !shadow-lg"
@mousedown="moved = false" @mousedown="moved = false"
@mousemove="moved = false" @mousemove="moved = false"
@mouseup="handleMouseUp(element, index)" @mouseup="handleMouseUp(element, index)"
> >
<div class="flex"> <div class="flex">
<div class="flex flex-row flex-1"> <div class="flex flex-1">
<LazySmartsheetHeaderVirtualCell <LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(element)" v-if="isVirtualCol(element)"
:column="{ ...element, title: element.label || element.title }" :column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)" :required="isRequired(element, element.required)"
:hide-menu="true" :hide-menu="true"
/> />
<LazySmartsheetHeaderCell <LazySmartsheetHeaderCell
v-else v-else
class="w-full"
:column="{ ...element, title: element.label || element.title }" :column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)" :required="isRequired(element, element.required)"
:hide-menu="true" :hide-menu="true"
/> />
</div> </div>
<div class="flex flex-row">
<MdiDragVertical class="flex flex-1" />
</div>
</div> </div>
</a-card> </a-card>
</template> </template>
<template #footer> <template #footer>
<div class="mt-4 border-dashed border-2 border-gray-400 py-3 text-gray-400 text-center nc-drag-n-drop-to-hide"> <div
class="my-4 select-none border-dashed border-2 border-gray-400 py-3 text-gray-400 text-center nc-drag-n-drop-to-hide"
>
<!-- Drag and drop fields here to hide --> <!-- Drag and drop fields here to hide -->
{{ $t('msg.info.dragDropHide') }} {{ $t('msg.info.dragDropHide') }}
</div> </div>
<a-dropdown v-model:visible="showColumnDropdown" :trigger="['click']" overlay-class-name="nc-dropdown-form-add-column"> <a-dropdown v-model:visible="showColumnDropdown" :trigger="['click']" overlay-class-name="nc-dropdown-form-add-column">
<a-button type="link" class="w-full caption mt-2" size="large" @click.stop="showColumnDropdown = true"> <button type="button" class="group w-full mt-2" @click.stop="showColumnDropdown = true">
<div class="flex items-center prose-sm justify-center text-gray-400"> <span class="flex items-center flex-wrap justify-center gap-2 prose-sm text-gray-400">
<mdi-plus /> <MdiPlus class="color-transition transform group-hover:(text-accent scale-125)" />
<!-- Add new field to this table --> <!-- Add new field to this table -->
{{ $t('activity.addField') }} <span class="color-transition group-hover:text-primary break-words">
</div> {{ $t('activity.addField') }}
</a-button> </span>
</span>
</button>
<template #overlay> <template #overlay>
<LazySmartsheetColumnEditOrAddProvider <SmartsheetColumnEditOrAddProvider
v-if="showColumnDropdown" v-if="showColumnDropdown"
@submit="submitCallback" @submit="submitCallback"
@cancel="showColumnDropdown = false" @cancel="showColumnDropdown = false"
@ -501,52 +507,60 @@ watch(view, (nextView) => {
</template> </template>
</Draggable> </Draggable>
</a-col> </a-col>
<a-col v-if="formViewData" :span="isEditable ? 16 : 24" class="h-full overflow-auto scrollbar-thin-primary">
<div class="h-[200px] !bg-[#dbdad7]"> <a-col v-if="formViewData" :span="isEditable ? 16 : 24" class="h-full overflow-auto scrollbar-thin-dull">
<div class="h-[200px] bg-primary bg-opacity-75">
<!-- for future implementation of cover image --> <!-- for future implementation of cover image -->
</div> </div>
<a-card <a-card
class="m-0 rounded-b-0 p-4 border-none" class="p-4 border-none"
:body-style="{ :body-style="{
maxWidth: '700px', maxWidth: 'max(50vw, 700px)',
margin: '0 auto', margin: '0 auto',
marginTop: '-200px', marginTop: '-200px',
padding: '0px',
}" }"
> >
<a-form ref="formRef" :model="formState" class="nc-form"> <a-form ref="formRef" :model="formState" class="nc-form" no-style>
<a-card class="rounded m-2 py-10 px-5"> <a-card class="!rounded !shadow !m-2 md:(!m-4) xl:(!m-8)" :body-style="{ paddingLeft: '0px', paddingRight: '0px' }">
<!-- Header --> <!-- Header -->
<a-form-item v-if="isEditable" class="m-0 gap-0 p-0"> <div v-if="isEditable" class="px-4 lg:px-12">
<a-input <a-form-item v-if="isEditable">
v-model:value="formViewData.heading" <a-input
class="w-full !font-bold !text-4xl" v-model:value="formViewData.heading"
size="large" class="w-full !font-bold !text-4xl !border-0 !border-b-1 !border-dashed !rounded-none !border-gray-400"
hide-details :style="{ borderRightWidth: '0px !important' }"
placeholder="Form Title" size="large"
:bordered="false" hide-details
@blur="updateView" placeholder="Form Title"
@keydown.enter="updateView" :bordered="false"
/> @blur="updateView"
</a-form-item> @keydown.enter="updateView"
/>
<div v-else class="ml-3 w-full text-bold text-h3">{{ formViewData.heading }}</div> </a-form-item>
</div>
<div v-else class="px-4 ml-3 w-full text-bold text-4xl">{{ formViewData.heading }}</div>
<!-- Sub Header --> <!-- Sub Header -->
<a-form-item v-if="isEditable" class="m-0 gap-0 p-0"> <div v-if="isEditable" class="px-4 lg:px-12">
<a-input <a-form-item>
v-model:value="formViewData.subheading" <a-input
class="w-full" v-model:value="formViewData.subheading"
size="large" class="w-full !border-0 !border-b-1 !border-dashed !rounded-none !border-gray-400"
hide-details :style="{ borderRightWidth: '0px !important' }"
:placeholder="$t('msg.info.formDesc')" size="large"
:bordered="false" hide-details
:disabled="!isEditable" :placeholder="$t('msg.info.formDesc')"
@blur="updateView" :bordered="false"
@click="updateView" :disabled="!isEditable"
/> @blur="updateView"
</a-form-item> @click="updateView"
/>
</a-form-item>
</div>
<div v-else class="ml-3 mb-5 w-full text-bold text-h3">{{ formViewData.subheading }}</div> <div v-else class="px-4 ml-3 w-full text-bold text-md">{{ formViewData.subheading || '---' }}</div>
<Draggable <Draggable
ref="draggableRef" ref="draggableRef"
@ -562,11 +576,11 @@ watch(view, (nextView) => {
> >
<template #item="{ element, index }"> <template #item="{ element, index }">
<div <div
class="nc-editable item cursor-pointer hover:(bg-primary bg-opacity-10) p-3 my-2 relative" class="color-transition nc-editable item cursor-pointer hover:(bg-primary bg-opacity-10 ring-1 ring-accent ring-opacity-100) px-4 lg:px-12 py-4 relative"
:class="[ :class="[
`nc-form-drag-${element.title.replaceAll(' ', '')}`, `nc-form-drag-${element.title.replaceAll(' ', '')}`,
{ {
'border-1': activeRow === element.title, 'bg-primary bg-opacity-5 ring-0.5 ring-accent ring-opacity-100': activeRow === element.title,
}, },
]" ]"
@click="activeRow = element.title" @click="activeRow = element.title"
@ -575,64 +589,53 @@ watch(view, (nextView) => {
v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)" v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)"
class="absolute flex top-2 right-2" class="absolute flex top-2 right-2"
> >
<mdi-eye-off-outline class="opacity-0 nc-field-remove-icon" @click.stop="hideColumn(index)" /> <MdiEyeOffOutline class="opacity-0 nc-field-remove-icon" @click.stop="hideColumn(index)" />
</div> </div>
<template v-if="activeRow === element.title">
<div class="flex"> <div v-if="activeRow === element.title" class="flex flex-col gap-3 mb-3">
<div <div class="flex gap-2 items-center">
class="flex flex-1 opacity-0 align-center gap-2" <span
:class="{ 'opacity-100': activeRow === element.title }" class="text-gray-500 mr-2 nc-form-input-required"
@click="
() => {
element.required = !element.required
updateColMeta(element)
}
"
> >
<div class="flex flex-row"> {{ $t('general.required') }}
<mdi-drag-vertical class="flex flex-1" /> </span>
</div>
<div class="items-center flex"> <a-switch
<span v-model:checked="element.required"
class="text-xs text-gray-500 mr-2 nc-form-input-required" v-e="['a:form-view:field:mark-required']"
@click=" size="small"
() => { @change="updateColMeta(element)"
element.required = !element.required />
updateColMeta(element)
}
"
>
{{ $t('general.required') }}
</span>
<a-switch
v-model:checked="element.required"
v-e="['a:form-view:field:mark-required']"
size="small"
class="ml-2"
@change="updateColMeta(element)"
/>
</div>
</div>
</div> </div>
<div class="my-3"> <a-form-item class="my-0 w-1/2 !mb-1">
<a-form-item class="my-0 w-1/2 !mb-1"> <a-input
<a-input v-model:value="element.label"
v-model:value="element.label" type="text"
size="small" class="form-meta-input nc-form-input-label"
class="form-meta-input !bg-[#dbdbdb] nc-form-input-label" :placeholder="$t('msg.info.formInput')"
:placeholder="$t('msg.info.formInput')" @change="updateColMeta(element)"
@change="updateColMeta(element)" >
> </a-input>
</a-input> </a-form-item>
</a-form-item>
<a-form-item class="mt-2 mb-0 w-1/2 !mb-1">
<a-form-item class="mt-2 mb-0 w-1/2 !mb-1"> <a-input
<a-input v-model:value="element.description"
v-model:value="element.description" type="text"
size="small" class="form-meta-input text-sm nc-form-input-help-text"
class="form-meta-input !bg-[#dbdbdb] text-sm nc-form-input-help-text" :placeholder="$t('msg.info.formHelpText')"
:placeholder="$t('msg.info.formHelpText')" @change="updateColMeta(element)"
@change="updateColMeta(element)" />
/> </a-form-item>
</a-form-item> </div>
</div>
</template>
<div> <div>
<LazySmartsheetHeaderVirtualCell <LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(element)" v-if="isVirtualCol(element)"
@ -640,6 +643,7 @@ watch(view, (nextView) => {
:required="isRequired(element, element.required)" :required="isRequired(element, element.required)"
:hide-menu="true" :hide-menu="true"
/> />
<LazySmartsheetHeaderCell <LazySmartsheetHeaderCell
v-else v-else
:column="{ ...element, title: element.label || element.title }" :column="{ ...element, title: element.label || element.title }"
@ -650,8 +654,8 @@ watch(view, (nextView) => {
<a-form-item <a-form-item
v-if="isVirtualCol(element)" v-if="isVirtualCol(element)"
class="!m-0 gap-0 p-0"
:name="element.title" :name="element.title"
class="!mb-0"
:rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]" :rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]"
> >
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
@ -666,8 +670,8 @@ watch(view, (nextView) => {
<a-form-item <a-form-item
v-else v-else
class="!m-0 gap-0 p-0"
:name="element.title" :name="element.title"
class="!mb-0"
:rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]" :rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]"
> >
<LazySmartsheetCell <LazySmartsheetCell
@ -680,7 +684,7 @@ watch(view, (nextView) => {
/> />
</a-form-item> </a-form-item>
<span class="text-gray-500 text-xs -mt-1 block">{{ element.description }}</span> <div class="text-gray-500 text-xs">{{ element.description }}</div>
</div> </div>
</template> </template>
@ -694,23 +698,24 @@ watch(view, (nextView) => {
</template> </template>
</Draggable> </Draggable>
<div class="justify-center flex mt-10"> <div class="justify-center flex mt-6">
<a-button type="primary" class="flex items-center gap-2 nc-form-submit" size="large" @click="submitForm"> <button type="submit" class="uppercase scaling-btn nc-form-submit" @click="submitForm">
<!-- Submit -->
{{ $t('general.submit') }} {{ $t('general.submit') }}
</a-button> </button>
</div> </div>
</a-card> </a-card>
</a-form> </a-form>
<div v-if="isEditable" class="mx-10 px-10"> <a-divider />
<div v-if="isEditable" class="px-4 flex flex-col gap-2">
<!-- After form is submitted --> <!-- After form is submitted -->
<div class="text-gray-500 mt-4 mb-2"> <div class="text-lg text-gray-700">
{{ $t('msg.info.afterFormSubmitted') }} {{ $t('msg.info.afterFormSubmitted') }}
</div> </div>
<!-- Show this message --> <!-- Show this message -->
<label class="text-gray-600 text-bold"> {{ $t('msg.info.showMessage') }}: </label> <div class="text-gray-500 text-bold">{{ $t('msg.info.showMessage') }}:</div>
<a-textarea <a-textarea
v-model:value="formViewData.success_msg" v-model:value="formViewData.success_msg"
:rows="3" :rows="3"
@ -720,8 +725,8 @@ watch(view, (nextView) => {
/> />
<!-- Other options --> <!-- Other options -->
<div class="mt-4"> <div class="flex flex-col gap-2 mt-4">
<div class="my-4"> <div class="flex items-center">
<!-- Show "Submit Another Form" button --> <!-- Show "Submit Another Form" button -->
<a-switch <a-switch
v-model:checked="formViewData.submit_another_form" v-model:checked="formViewData.submit_another_form"
@ -733,7 +738,7 @@ watch(view, (nextView) => {
<span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span> <span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span>
</div> </div>
<div class="my-4"> <div class="flex items-center">
<!-- Show a blank form after 5 seconds --> <!-- Show a blank form after 5 seconds -->
<a-switch <a-switch
v-model:checked="formViewData.show_blank_form" v-model:checked="formViewData.show_blank_form"
@ -742,10 +747,11 @@ watch(view, (nextView) => {
class="nc-form-checkbox-show-blank-form" class="nc-form-checkbox-show-blank-form"
@change="updateView" @change="updateView"
/> />
<span class="ml-4">{{ $t('msg.info.showBlankForm') }}</span> <span class="ml-4">{{ $t('msg.info.showBlankForm') }}</span>
</div> </div>
<div class="my-4"> <div class="mb-12 flex items-center">
<a-switch <a-switch
v-model:checked="emailMe" v-model:checked="emailMe"
v-e="[`a:form-view:email-me`]" v-e="[`a:form-view:email-me`]"
@ -753,6 +759,7 @@ watch(view, (nextView) => {
class="nc-form-checkbox-send-email" class="nc-form-checkbox-send-email"
@change="onEmailChange" @change="onEmailChange"
/> />
<!-- Email me at <email> --> <!-- Email me at <email> -->
<span class="ml-4"> <span class="ml-4">
{{ $t('msg.info.emailForm') }} <span class="text-bold text-gray-600">{{ state.user.value?.email }}</span> {{ $t('msg.info.emailForm') }} <span class="text-bold text-gray-600">{{ state.user.value?.email }}</span>
@ -786,4 +793,20 @@ watch(view, (nextView) => {
@apply !text-gray-500 !text-xs; @apply !text-gray-500 !text-xs;
} }
} }
:deep(.nc-cell-attachment) {
@apply p-0;
.nc-attachment-cell {
@apply px-4 min-h-[75px] w-full h-full;
.nc-attachment {
@apply md:(w-[50px] h-[50px]) lg:(w-[75px] h-[75px]) min-h-[50px] min-w-[50px];
}
.nc-attachment-cell-dropzone {
@apply rounded bg-gray-400/75;
}
}
}
</style> </style>

293
packages/nc-gui/components/smartsheet/Grid.vue

@ -365,93 +365,92 @@ watch([() => selected.row, () => selected.col], ([row, col]) => {
@contextmenu="showContextMenu" @contextmenu="showContextMenu"
> >
<thead> <thead>
<tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]"> <tr class="nc-grid-header border-1 bg-gray-100 sticky top[-1px]">
<th> <th>
<div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center"> <div class="w-full h-full bg-gray-100 flex min-w-[70px] pl-5 pr-1 items-center">
<template v-if="!readOnly"> <template v-if="!readOnly">
<div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div> <div class="nc-no-label text-gray-500" :class="{ hidden: selectedAllRecords }">#</div>
<div <div
:class="{ hidden: !selectedAllRecords, flex: selectedAllRecords }" :class="{ hidden: !selectedAllRecords, flex: selectedAllRecords }"
class="nc-check-all w-full items-center" class="nc-check-all w-full items-center"
> >
<a-checkbox v-model:checked="selectedAllRecords" /> <a-checkbox v-model:checked="selectedAllRecords" />
<span class="flex-1" /> <span class="flex-1" />
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div class="text-gray-500">#</div> <div class="text-gray-500">#</div>
</template> </template>
</div> </div>
</th> </th>
<th <th
v-for="col in fields" v-for="col in fields"
:key="col.title" :key="col.title"
v-xc-ver-resize v-xc-ver-resize
:data-col="col.id" :data-col="col.id"
:data-title="col.title" :data-title="col.title"
@xcresize="onresize(col.id, $event)" @xcresize="onresize(col.id, $event)"
@xcresizing="onXcResizing(col.title, $event)" @xcresizing="onXcResizing(col.title, $event)"
@xcresized="resizingCol = null" @xcresized="resizingCol = null"
>
<div class="w-full h-full bg-gray-100 flex items-center">
<LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="readOnly" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="readOnly" />
</div>
</th>
<th
v-if="!readOnly && !isLocked && isUIAllowed('add-column') && !isSqlView"
v-e="['c:column:add']"
class="cursor-pointer"
@click.stop="addColumnDropdown = true"
>
<a-dropdown
v-model:visible="addColumnDropdown"
:trigger="['click']"
overlay-class-name="nc-dropdown-grid-add-column"
> >
<div class="h-full w-[60px] flex items-center justify-center"> <div class="w-full h-full bg-gray-100 flex items-center">
<MdiPlus class="text-sm nc-column-add" /> <LazySmartsheetHeaderVirtualCell v-if="isVirtualCol(col)" :column="col" :hide-menu="readOnly" />
<LazySmartsheetHeaderCell v-else :column="col" :hide-menu="readOnly" />
</div> </div>
</th>
<th
v-if="!readOnly && !isLocked && isUIAllowed('add-column') && !isSqlView"
v-e="['c:column:add']"
class="cursor-pointer"
@click.stop="addColumnDropdown = true"
>
<a-dropdown
v-model:visible="addColumnDropdown"
:trigger="['click']"
overlay-class-name="nc-dropdown-grid-add-column"
>
<div class="h-full w-[60px] flex items-center justify-center">
<MdiPlus class="text-sm nc-column-add" />
</div>
<template #overlay> <template #overlay>
<SmartsheetColumnEditOrAddProvider <SmartsheetColumnEditOrAddProvider
v-if="addColumnDropdown" v-if="addColumnDropdown"
@submit="addColumnDropdown = false" @submit="addColumnDropdown = false"
@cancel="addColumnDropdown = false" @cancel="addColumnDropdown = false"
@click.stop @click.stop
@keydown.stop @keydown.stop
/> />
</template> </template>
</a-dropdown> </a-dropdown>
</th> </th>
</tr> </tr>
</thead> </thead>
<!-- this prevent select text from field if not in edit mode --> <!-- this prevent select text from field if not in edit mode -->
<tbody ref="tbodyEl" @selectstart.prevent> <tbody ref="tbodyEl" @selectstart.prevent>
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row"> <LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }"> <template #default="{ state }">
<tr class="nc-grid-row"> <tr class="nc-grid-row">
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1"> <td key="row-index" class="caption nc-grid-cell pl-5 pr-1">
<div class="items-center flex gap-1 min-w-[55px]"> <div class="items-center flex gap-1 min-w-[55px]">
<div <div
v-if="!readOnly || !isLocked" v-if="!readOnly || !isLocked"
class="nc-row-no text-xs text-gray-500" class="nc-row-no text-xs text-gray-500"
:class="{ toggle: !readOnly, hidden: row.rowMeta.selected }" :class="{ toggle: !readOnly, hidden: row.rowMeta.selected }"
> >
{{ rowIndex + 1 }} {{ rowIndex + 1 }}
</div> </div>
<div <div
v-if="!readOnly" v-if="!readOnly"
:class="{ hidden: !row.rowMeta.selected, flex: row.rowMeta.selected }" :class="{ hidden: !row.rowMeta.selected, flex: row.rowMeta.selected }"
class="nc-row-expand-and-checkbox" class="nc-row-expand-and-checkbox"
> >
<a-checkbox v-model:checked="row.rowMeta.selected" /> <a-checkbox v-model:checked="row.rowMeta.selected" />
</div> </div>
<span class="flex-1" /> <span class="flex-1" />
<div v-if="!readOnly && !isLocked" class="nc-expand" <div v-if="!readOnly && !isLocked" class="nc-expand" :class="{ 'nc-comment': row.rowMeta?.commentCount }">
:class="{ 'nc-comment': row.rowMeta?.commentCount }">
<span <span
v-if="row.rowMeta?.commentCount" v-if="row.rowMeta?.commentCount"
class="py-1 px-3 rounded-full text-xs cursor-pointer select-none transform hover:(scale-110)" class="py-1 px-3 rounded-full text-xs cursor-pointer select-none transform hover:(scale-110)"
@ -460,82 +459,82 @@ watch([() => selected.row, () => selected.col], ([row, col]) => {
> >
{{ row.rowMeta.commentCount }} {{ row.rowMeta.commentCount }}
</span> </span>
<div <div
v-else v-else
class="cursor-pointer flex items-center border-1 active:ring rounded p-1 hover:(bg-primary bg-opacity-10)" class="cursor-pointer flex items-center border-1 active:ring rounded p-1 hover:(bg-primary bg-opacity-10)"
> >
<MdiArrowExpand <MdiArrowExpand
v-e="['c:row-expand']" v-e="['c:row-expand']"
class="select-none transform hover:(text-accent scale-120) nc-row-expand" class="select-none transform hover:(text-accent scale-120) nc-row-expand"
@click="expandForm(row, state)" @click="expandForm(row, state)"
/> />
</div>
</div> </div>
</div> </div>
</div> </td>
</td> <td
<td v-for="(columnObj, colIndex) of fields"
v-for="(columnObj, colIndex) of fields" :ref="cellRefs.set"
:ref="cellRefs.set" :key="columnObj.id"
:key="columnObj.id" class="cell relative cursor-pointer nc-grid-cell"
class="cell relative cursor-pointer nc-grid-cell" :class="{
:class="{
active: active:
(isUIAllowed('xcDatatableEditable') && selected.col === colIndex && selected.row === rowIndex) || (isUIAllowed('xcDatatableEditable') && selected.col === colIndex && selected.row === rowIndex) ||
(isUIAllowed('xcDatatableEditable') && selectedRange(rowIndex, colIndex)), (isUIAllowed('xcDatatableEditable') && selectedRange(rowIndex, colIndex)),
}" }"
:data-key="rowIndex + columnObj.id" :data-key="rowIndex + columnObj.id"
:data-col="columnObj.id" :data-col="columnObj.id"
:data-title="columnObj.title" :data-title="columnObj.title"
@click="selectCell(rowIndex, colIndex)" @click="selectCell(rowIndex, colIndex)"
@dblclick="makeEditable(row, columnObj)" @dblclick="makeEditable(row, columnObj)"
@mousedown="startSelectRange($event, rowIndex, colIndex)" @mousedown="startSelectRange($event, rowIndex, colIndex)"
@mouseover="selectBlock(rowIndex, colIndex)" @mouseover="selectBlock(rowIndex, colIndex)"
@contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })" @contextmenu="showContextMenu($event, { row: rowIndex, col: colIndex })"
> >
<div class="w-full h-full"> <div class="w-full h-full">
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(columnObj)" v-if="isVirtualCol(columnObj)"
v-model="row.row[columnObj.title]" v-model="row.row[columnObj.title]"
:column="columnObj" :column="columnObj"
:active="selected.col === colIndex && selected.row === rowIndex" :active="selected.col === colIndex && selected.row === rowIndex"
:row="row" :row="row"
@navigate="onNavigate" @navigate="onNavigate"
/> />
<LazySmartsheetCell
v-else
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="hasEditPermission && editEnabled && selected.col === colIndex && selected.row === rowIndex"
:row-index="rowIndex"
:active="selected.col === colIndex && selected.row === rowIndex"
@update:edit-enabled="editEnabled = false"
@save="updateOrSaveRow(row, columnObj.title)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</td>
</tr>
</template>
</LazySmartsheetRow>
<tr v-if="!isView && !isLocked && hasEditPermission && !isSqlView">
<td
v-e="['c:row:add:grid-bottom']"
:colspan="visibleColLength + 1"
class="text-left pointer nc-grid-add-new-cell cursor-pointer"
@click="addEmptyRow()"
>
<div class="px-2 w-full flex items-center text-gray-500">
<MdiPlus class="text-pint-500 text-xs ml-2 text-primary" />
<LazySmartsheetCell <span class="ml-1">
v-else
v-model="row.row[columnObj.title]"
:column="columnObj"
:edit-enabled="hasEditPermission && editEnabled && selected.col === colIndex && selected.row === rowIndex"
:row-index="rowIndex"
:active="selected.col === colIndex && selected.row === rowIndex"
@update:edit-enabled="editEnabled = false"
@save="updateOrSaveRow(row, columnObj.title)"
@navigate="onNavigate"
@cancel="editEnabled = false"
/>
</div>
</td>
</tr>
</template>
</LazySmartsheetRow>
<tr v-if="!isView && !isLocked && hasEditPermission && !isSqlView">
<td
v-e="['c:row:add:grid-bottom']"
:colspan="visibleColLength + 1"
class="text-left pointer nc-grid-add-new-cell cursor-pointer"
@click="addEmptyRow()"
>
<div class="px-2 w-full flex items-center text-gray-500">
<MdiPlus class="text-pint-500 text-xs ml-2 text-primary" />
<span class="ml-1">
{{ $t('activity.addRow') }} {{ $t('activity.addRow') }}
</span> </span>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

10
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -14,7 +14,7 @@ const { allowCSVDownload } = useSharedView()
<template> <template>
<div <div
class="nc-table-toolbar w-full py-1 flex gap-1 items-center h-[var(--toolbar-height)] px-2 border-b overflow-x-hidden" class="nc-table-toolbar w-full py-1 flex gap-2 items-center h-[var(--toolbar-height)] px-2 border-b overflow-x-hidden"
style="z-index: 7" style="z-index: 7"
> >
<LazySmartsheetToolbarViewActions <LazySmartsheetToolbarViewActions
@ -25,7 +25,7 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarViewInfo v-if="!isUIAllowed('dataInsert') && !isPublic" /> <LazySmartsheetToolbarViewInfo v-if="!isUIAllowed('dataInsert') && !isPublic" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery" :show-system-fields="false" class="ml-1" /> <LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery" :show-system-fields="false" />
<LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery" /> <LazySmartsheetToolbarColumnFilterMenu v-if="isGrid || isGallery" />
@ -37,11 +37,11 @@ const { allowCSVDownload } = useSharedView()
<div class="flex-1" /> <div class="flex-1" />
<LazySmartsheetToolbarReload v-if="!isPublic && !isForm" class="mx-1" /> <LazySmartsheetToolbarReload v-if="!isPublic && !isForm" />
<LazySmartsheetToolbarAddRow v-if="isUIAllowed('dataInsert') && !isPublic && !isForm && !isSqlView" class="mx-1" /> <LazySmartsheetToolbarAddRow v-if="isUIAllowed('dataInsert') && !isPublic && !isForm && !isSqlView" />
<LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery) && !isPublic" class="shrink mr-2 ml-2" /> <LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery) && !isPublic" class="shrink mx-2" />
<template v-if="!isOpen && !isPublic"> <template v-if="!isOpen && !isPublic">
<div class="border-l-1 pl-3"> <div class="border-l-1 pl-3">

14
packages/nc-gui/components/smartsheet/VirtualCell.vue

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ColumnType } from 'nocodb-sdk' import type { ColumnType } from 'nocodb-sdk'
import { ActiveCellInj, CellValueInj, ColumnInj, RowInj, provide, toRef, useVirtualCell } from '#imports' import { ActiveCellInj, CellValueInj, ColumnInj, IsFormInj, RowInj, inject, provide, ref, toRef, useVirtualCell } from '#imports'
import type { Row } from '~/lib' import type { Row } from '~/lib'
import { NavigateDir } from '~/lib' import { NavigateDir } from '~/lib'
@ -22,14 +22,22 @@ provide(ActiveCellInj, active)
provide(RowInj, row) provide(RowInj, row)
provide(CellValueInj, toRef(props, 'modelValue')) provide(CellValueInj, toRef(props, 'modelValue'))
const isForm = inject(IsFormInj, ref(false))
const { isLookup, isBt, isRollup, isMm, isHm, isFormula, isCount } = useVirtualCell(column) const { isLookup, isBt, isRollup, isMm, isHm, isFormula, isCount } = useVirtualCell(column)
function onNavigate(dir: NavigateDir, e: KeyboardEvent) {
emit('navigate', dir)
if (!isForm.value) e.stopImmediatePropagation()
}
</script> </script>
<template> <template>
<div <div
class="nc-virtual-cell w-full" class="nc-virtual-cell w-full"
@keydown.stop.enter.exact="emit('navigate', NavigateDir.NEXT)" @keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.stop.shift.enter.exact="emit('navigate', NavigateDir.PREV)" @keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
> >
<LazyVirtualCellHasMany v-if="isHm" /> <LazyVirtualCellHasMany v-if="isHm" />
<LazyVirtualCellManyToMany v-else-if="isMm" /> <LazyVirtualCellManyToMany v-else-if="isMm" />

5
packages/nc-gui/components/smartsheet/sidebar/index.vue

@ -108,14 +108,15 @@ async function onCreate(view: ViewType) {
:collapsed="sidebarCollapsed" :collapsed="sidebarCollapsed"
collapsiple collapsiple
collapsed-width="0" collapsed-width="0"
width="250" width="0"
class="relative shadow-md h-full" class="relative shadow h-full w-full !flex-1 !min-w-0 !max-w-[150px] !w-[150px] lg:(!max-w-[250px] !w-[250px])"
theme="light" theme="light"
> >
<LazySmartsheetSidebarToolbar <LazySmartsheetSidebarToolbar
v-if="isOpen" v-if="isOpen"
class="min-h-[var(--toolbar-height)] max-h-[var(--toolbar-height)] flex items-center py-3 px-3 justify-between border-b-1" class="min-h-[var(--toolbar-height)] max-h-[var(--toolbar-height)] flex items-center py-3 px-3 justify-between border-b-1"
/> />
<div v-if="isOpen" class="flex-1 flex flex-col min-h-0"> <div v-if="isOpen" class="flex-1 flex flex-col min-h-0">
<LazySmartsheetSidebarMenuTop @open-modal="openModal" @deleted="loadViews" /> <LazySmartsheetSidebarMenuTop @open-modal="openModal" @deleted="loadViews" />

2
packages/nc-gui/components/smartsheet/toolbar/AddRow.vue

@ -16,7 +16,7 @@ const onClick = () => {
<div <div
v-e="['c:row:add:grid-top']" v-e="['c:row:add:grid-top']"
:class="{ 'group': !isLocked, 'disabled-ring': isLocked }" :class="{ 'group': !isLocked, 'disabled-ring': isLocked }"
class="nc-add-new-row-btn flex align-center" class="nc-add-new-row-btn nc-toolbar-btn flex min-w-32px w-32px h-32px items-center"
> >
<MdiPlusOutline <MdiPlusOutline
:class="{ 'cursor-pointer text-gray-500 group-hover:(text-primary)': !isLocked, 'disabled': isLocked }" :class="{ 'cursor-pointer text-gray-500 group-hover:(text-primary)': !isLocked, 'disabled': isLocked }"

22
packages/nc-gui/components/smartsheet/toolbar/Reload.vue

@ -1,13 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { ReloadViewDataHookInj, inject, useNuxtApp } from '#imports' import { ReloadViewDataHookInj, inject, ref, useNuxtApp, watch } from '#imports'
const { $e } = useNuxtApp() const { $e, $state } = useNuxtApp()
const reloadHook = inject(ReloadViewDataHookInj)! const reloadHook = inject(ReloadViewDataHookInj)!
const isReloading = ref(false)
const onClick = () => { const onClick = () => {
$e('a:table:reload:navbar') $e('a:table:reload:navbar')
isReloading.value = true
reloadHook.trigger() reloadHook.trigger()
const stop = watch($state.isLoading, (isLoading) => {
if (!isLoading) {
isReloading.value = false
stop()
}
})
} }
</script> </script>
@ -15,8 +25,12 @@ const onClick = () => {
<a-tooltip placement="bottom"> <a-tooltip placement="bottom">
<template #title> {{ $t('general.reload') }} </template> <template #title> {{ $t('general.reload') }} </template>
<div class="group flex align-center"> <div class="nc-toolbar-btn flex min-w-32px w-32px h-32px items-center">
<MdiReload class="cursor-pointer text-gray-500 group-hover:(text-primary) nc-toolbar-reload-btn" @click="onClick" /> <MdiReload
class="w-full h-full cursor-pointer text-gray-500 group-hover:(text-primary) nc-toolbar-reload-btn"
:class="isReloading ? 'animate-spin' : ''"
@click="onClick"
/>
</div> </div>
</a-tooltip> </a-tooltip>
</template> </template>

172
packages/nc-gui/components/smartsheet/toolbar/ShareView.vue

@ -1,9 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import tinycolor from 'tinycolor2'
import { import {
computed, computed,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
message, message,
projectThemeColors,
ref, ref,
useCopy, useCopy,
useDashboard, useDashboard,
@ -14,6 +17,7 @@ import {
useUIPermission, useUIPermission,
watch, watch,
} from '#imports' } from '#imports'
import type { SharedView } from '~/lib'
const { t } = useI18n() const { t } = useI18n()
@ -33,31 +37,52 @@ let showShareModel = $ref(false)
const passwordProtected = ref(false) const passwordProtected = ref(false)
const shared = ref() const shared = ref<SharedView>({ id: '', meta: {}, password: undefined })
const allowCSVDownload = computed({ const allowCSVDownload = computed({
get() { get: () => !!shared.value.meta.allowCSVDownload,
return !!(shared.value?.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta) set: (allow) => {
?.allowCSVDownload shared.value.meta = { ...shared.value.meta, allowCSVDownload: allow }
},
set(allow) {
shared.value.meta = { allowCSVDownload: allow }
saveAllowCSVDownload() saveAllowCSVDownload()
}, },
}) })
const surveyMode = computed({
get: () => !!shared.value.meta.surveyMode,
set: (survey) => {
shared.value.meta = { ...shared.value.meta, surveyMode: survey }
saveSurveyMode()
},
})
const viewTheme = computed({
get: () => !!shared.value.meta.withTheme,
set: (withTheme) => {
shared.value.meta = {
...shared.value.meta,
withTheme,
}
saveTheme()
},
})
const genShareLink = async () => { const genShareLink = async () => {
shared.value = await $api.dbViewShare.create(view.value?.id as string) if (!view.value?.id) return
shared.value.meta =
shared.value.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta const response = (await $api.dbViewShare.create(view.value.id)) as SharedView
passwordProtected.value = shared.value.password !== null && shared.value.password !== '' const meta = isString(response.meta) ? JSON.parse(response.meta) : response.meta
shared.value = { ...response, meta }
passwordProtected.value = !!shared.value.password && shared.value.password !== ''
showShareModel = true showShareModel = true
} }
const sharedViewUrl = computed(() => { const sharedViewUrl = computed(() => {
if (!shared.value) return if (!shared.value) return
let viewType
let viewType
switch (shared.value.type) { switch (shared.value.type) {
case ViewTypes.FORM: case ViewTypes.FORM:
viewType = 'form' viewType = 'form'
@ -73,21 +98,34 @@ const sharedViewUrl = computed(() => {
}) })
async function saveAllowCSVDownload() { async function saveAllowCSVDownload() {
await updateSharedViewMeta()
$e(`a:view:share:${allowCSVDownload.value ? 'enable' : 'disable'}-csv-download`)
}
async function saveSurveyMode() {
await updateSharedViewMeta()
$e(`a:view:share:${surveyMode.value ? 'enable' : 'disable'}-survey-mode`)
}
async function saveTheme() {
await updateSharedViewMeta()
$e(`a:view:share:${viewTheme.value ? 'enable' : 'disable'}-theme`)
}
async function updateSharedViewMeta() {
try { try {
const meta = shared.value.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta const meta = shared.value.meta && isString(shared.value.meta) ? JSON.parse(shared.value.meta) : shared.value.meta
await $api.dbViewShare.update(shared.value.id, { await $api.dbViewShare.update(shared.value.id, {
meta, meta,
}) })
// Successfully updated
message.success(t('msg.success.updated')) message.success(t('msg.success.updated'))
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
if (allowCSVDownload?.value) {
$e('a:view:share:enable-csv-download') return true
} else {
$e('a:view:share:disable-csv-download')
}
} }
const saveShareLinkPassword = async () => { const saveShareLinkPassword = async () => {
@ -104,10 +142,27 @@ const saveShareLinkPassword = async () => {
$e('a:view:share:enable-pwd') $e('a:view:share:enable-pwd')
} }
const copyLink = () => { function onChangeTheme(color: string) {
copy(sharedViewUrl?.value as string) const tcolor = tinycolor(color)
// Copied to clipboard
message.success(t('msg.info.copiedToClipboard')) if (tcolor.isValid()) {
const complement = tcolor.complement()
shared.value.meta.theme = {
primaryColor: color,
accentColor: complement.toHex8String(),
}
saveTheme()
}
}
const copyLink = async () => {
if (sharedViewUrl.value) {
await copy(sharedViewUrl.value)
// Copied to clipboard
message.success(t('msg.info.copiedToClipboard'))
}
} }
watch(passwordProtected, (value) => { watch(passwordProtected, (value) => {
@ -125,8 +180,9 @@ watch(passwordProtected, (value) => {
v-e="['c:view:share']" v-e="['c:view:share']"
outlined outlined
class="nc-btn-share-view nc-toolbar-btn" class="nc-btn-share-view nc-toolbar-btn"
@click="genShareLink"
> >
<div class="flex items-center gap-1" @click="genShareLink"> <div class="flex items-center gap-1">
<MdiOpenInNew /> <MdiOpenInNew />
<!-- Share View --> <!-- Share View -->
<span class="!text-sm font-weight-normal"> {{ $t('activity.shareView') }}</span> <span class="!text-sm font-weight-normal"> {{ $t('activity.shareView') }}</span>
@ -139,10 +195,13 @@ watch(passwordProtected, (value) => {
size="small" size="small"
:title="$t('msg.info.privateLink')" :title="$t('msg.info.privateLink')"
:footer="null" :footer="null"
width="min(100vw,640px)" width="min(100vw,720px)"
wrap-class-name="nc-modal-share-view" wrap-class-name="nc-modal-share-view"
> >
<div class="share-link-box nc-share-link-box bg-primary-50"> <div
data-cy="nc-modal-share-view__link"
class="share-link-box !bg-primary !bg-opacity-5 ring-1 ring-accent ring-opacity-100"
>
<div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div> <div class="flex-1 h-min text-xs">{{ sharedViewUrl }}</div>
<a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank"> <a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank">
@ -152,33 +211,76 @@ watch(passwordProtected, (value) => {
<MdiContentCopy v-e="['c:view:share:copy-url']" class="text-gray-500 text-sm cursor-pointer" @click="copyLink" /> <MdiContentCopy v-e="['c:view:share:copy-url']" class="text-gray-500 text-sm cursor-pointer" @click="copyLink" />
</div> </div>
<a-collapse ghost> <div class="px-1 mt-2 flex flex-col gap-3">
<a-collapse-panel key="1" :header="$t('general.showOptions')"> <!-- todo: i18n -->
<div class="mb-2"> <div class="text-gray-500 border-b-1">Options</div>
<a-checkbox v-model:checked="passwordProtected" class="!text-xs">{{ $t('msg.info.beforeEnablePwd') }} </a-checkbox>
<div class="px-1 flex flex-col gap-2">
<div>
<!-- Survey Mode; todo: i18n -->
<a-checkbox
v-if="shared.type === ViewTypes.FORM"
v-model:checked="surveyMode"
data-cy="nc-modal-share-view__survey-mode"
class="!text-xs"
>
Use Survey Mode
</a-checkbox>
</div>
<div v-if="passwordProtected" class="flex gap-2 mt-2 mb-4"> <div>
<!-- todo: i18n -->
<a-checkbox v-model:checked="viewTheme" data-cy="nc-modal-share-view__with-theme" class="!text-xs">
Use Theme
</a-checkbox>
<div v-if="viewTheme" class="flex pl-2">
<LazyGeneralColorPicker
data-cy="nc-modal-share-view__theme-picker"
:model-value="shared.meta.theme?.primaryColor"
:colors="projectThemeColors"
:row-size="9"
:advanced="false"
@input="onChangeTheme"
/>
</div>
</div>
<div>
<!-- Password Protection -->
<a-checkbox v-model:checked="passwordProtected" data-cy="nc-modal-share-view__with-password" class="!text-xs">
{{ $t('msg.info.beforeEnablePwd') }}
</a-checkbox>
<div v-if="passwordProtected" class="ml-6 flex gap-2 mt-2 mb-4">
<a-input <a-input
v-model:value="shared.password" v-model:value="shared.password"
data-cy="nc-modal-share-view__password"
size="small" size="small"
class="!text-xs max-w-[250px]" class="!text-xs max-w-[250px]"
type="password" type="password"
:placeholder="$t('placeholder.password.enter')" :placeholder="$t('placeholder.password.enter')"
/> />
<a-button size="small" class="!text-xs" @click="saveShareLinkPassword"> <a-button data-cy="nc-modal-share-view__save-password" size="small" class="!text-xs" @click="saveShareLinkPassword">
{{ $t('placeholder.password.save') }} {{ $t('placeholder.password.save') }}
</a-button> </a-button>
</div> </div>
</div> </div>
<div> <div>
<!-- Allow Download --> <!-- Allow Download -->
<a-checkbox v-if="shared && shared.type === ViewTypes.GRID" v-model:checked="allowCSVDownload" class="!text-xs"> <a-checkbox
v-if="shared && shared.type === ViewTypes.GRID"
v-model:checked="allowCSVDownload"
data-cy="nc-modal-share-view__with-csv-download"
class="!text-xs"
>
{{ $t('labels.downloadAllowed') }} {{ $t('labels.downloadAllowed') }}
</a-checkbox> </a-checkbox>
</div> </div>
</a-collapse-panel> </div>
</a-collapse> </div>
</a-modal> </a-modal>
</div> </div>
</template> </template>

2
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -35,7 +35,7 @@ const readOnly = inject(ReadonlyInj, false)
const isForm = inject(IsFormInj, ref(false)) const isForm = inject(IsFormInj, ref(false))
const isLocked = inject(IsLockedInj) const isLocked = inject(IsLockedInj, ref(false))
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()

22
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -3,9 +3,11 @@ import { minLength, required } from '@vuelidate/validators'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { ColumnType, FormType, LinkToAnotherRecordType, TableType, ViewType } from 'nocodb-sdk' import type { ColumnType, FormType, LinkToAnotherRecordType, TableType, ViewType } from 'nocodb-sdk'
import { ErrorMessages, RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk' import { ErrorMessages, RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import { import {
SharedViewPasswordInj, SharedViewPasswordInj,
computed, computed,
createEventHook,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
message, message,
provide, provide,
@ -16,6 +18,7 @@ import {
useProvideSmartsheetRowStore, useProvideSmartsheetRowStore,
watch, watch,
} from '#imports' } from '#imports'
import type { SharedViewMeta } from '~/lib'
const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((sharedViewId: string) => { const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((sharedViewId: string) => {
const progress = ref(false) const progress = ref(false)
@ -23,6 +26,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const submitted = ref(false) const submitted = ref(false)
const passwordDlg = ref(false) const passwordDlg = ref(false)
const password = ref<string | null>(null) const password = ref<string | null>(null)
const passwordError = ref<string | null>(null)
const secondsRemain = ref(0) const secondsRemain = ref(0)
provide(SharedViewPasswordInj, password) provide(SharedViewPasswordInj, password)
@ -31,6 +35,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const sharedFormView = ref<FormType>() const sharedFormView = ref<FormType>()
const meta = ref<TableType>() const meta = ref<TableType>()
const columns = ref<(ColumnType & { required?: boolean; show?: boolean; label?: string })[]>() const columns = ref<(ColumnType & { required?: boolean; show?: boolean; label?: string })[]>()
const sharedViewMeta = ref<SharedViewMeta>({})
const formResetHook = createEventHook<void>()
const { api, isLoading } = useApi() const { api, isLoading } = useApi()
@ -51,6 +57,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
columns.value?.filter((c) => c.show).filter((col) => !isVirtualCol(col) || col.uidt === UITypes.LinkToAnotherRecord), columns.value?.filter((c) => c.show).filter((col) => !isVirtualCol(col) || col.uidt === UITypes.LinkToAnotherRecord),
) )
const loadSharedView = async () => { const loadSharedView = async () => {
passwordError.value = null
try { try {
const viewMeta = await api.public.sharedViewMetaGet(sharedViewId, { const viewMeta = await api.public.sharedViewMetaGet(sharedViewId, {
headers: { headers: {
@ -65,6 +73,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
meta.value = viewMeta.model meta.value = viewMeta.model
columns.value = viewMeta.model?.columns columns.value = viewMeta.model?.columns
const _sharedViewMeta = (viewMeta as any).meta
sharedViewMeta.value = isString(_sharedViewMeta) ? JSON.parse(_sharedViewMeta) : _sharedViewMeta
await setMeta(viewMeta.model) await setMeta(viewMeta.model)
const relatedMetas = { ...viewMeta.relatedMetas } const relatedMetas = { ...viewMeta.relatedMetas }
@ -75,6 +86,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
notFound.value = true notFound.value = true
} else if ((await extractSdkResponseErrorMsg(e)) === ErrorMessages.INVALID_SHARED_VIEW_PASSWORD) { } else if ((await extractSdkResponseErrorMsg(e)) === ErrorMessages.INVALID_SHARED_VIEW_PASSWORD) {
passwordDlg.value = true passwordDlg.value = true
if (password.value && password.value !== '') passwordError.value = 'Something went wrong. Please check your credentials.'
} }
} }
} }
@ -174,6 +187,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
if (secondsRemain.value < 0) { if (secondsRemain.value < 0) {
submitted.value = false submitted.value = false
formResetHook.trigger()
clearInterval(intvl) clearInterval(intvl)
} }
}, 1000) }, 1000)
@ -187,6 +202,10 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
} }
}) })
watch(password, (next, prev) => {
if (next !== prev && passwordError.value) passwordError.value = null
})
return { return {
sharedView, sharedView,
sharedFormView, sharedFormView,
@ -201,10 +220,13 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
formState, formState,
notFound, notFound,
password, password,
passwordError,
submitted, submitted,
secondsRemain, secondsRemain,
passwordDlg, passwordDlg,
isLoading, isLoading,
sharedViewMeta,
onReset: formResetHook.on,
} }
}, 'expanded-form-store') }, 'expanded-form-store')

6
packages/nc-gui/composables/useTheme/index.ts

@ -16,7 +16,7 @@ export const useTheme = createGlobalState((config?: Partial<ThemeConfig>) => {
/** set initial config */ /** set initial config */
setTheme(config ?? currentTheme.value) setTheme(config ?? currentTheme.value)
/** set theme (persists in localstorage) */ /** set theme */
function setTheme(theme?: Partial<ThemeConfig>) { function setTheme(theme?: Partial<ThemeConfig>) {
const themePrimary = theme?.primaryColor ? tinycolor(theme.primaryColor) : tinycolor(themeV2Colors['royal-blue'].DEFAULT) const themePrimary = theme?.primaryColor ? tinycolor(theme.primaryColor) : tinycolor(themeV2Colors['royal-blue'].DEFAULT)
const themeAccent = theme?.accentColor ? tinycolor(theme.accentColor) : tinycolor(themeV2Colors.pink['500']) const themeAccent = theme?.accentColor ? tinycolor(theme.accentColor) : tinycolor(themeV2Colors.pink['500'])
@ -28,8 +28,8 @@ export const useTheme = createGlobalState((config?: Partial<ThemeConfig>) => {
accentColor.value = themeAccent.isValid() ? hexToRGB(themeAccent.toHex8String()) : hexToRGB(themeV2Colors.pink['500']) accentColor.value = themeAccent.isValid() ? hexToRGB(themeAccent.toHex8String()) : hexToRGB(themeV2Colors.pink['500'])
currentTheme.value = { currentTheme.value = {
primaryColor: themePrimary.toHex8String().toUpperCase(), primaryColor: themePrimary.toHex8String().toUpperCase().slice(0, -2),
accentColor: themeAccent.toHex8String().toUpperCase(), accentColor: themeAccent.toHex8String().toUpperCase().slice(0, -2),
} }
ConfigProvider.config({ ConfigProvider.config({

1
packages/nc-gui/context/index.ts

@ -31,3 +31,4 @@ export const ViewListInj: InjectionKey<Ref<ViewType[]>> = Symbol('view-list-inje
export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection') export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection')
export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('shared-view-password-injection') export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('shared-view-password-injection')
export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url') export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url')
export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-zone-ref')

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

@ -1,4 +1,4 @@
import type { FilterType } from 'nocodb-sdk' import type { FilterType, ViewTypes } from 'nocodb-sdk'
import type { I18n } from 'vue-i18n' import type { I18n } from 'vue-i18n'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider' import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { ProjectRole, Role, TabType } from './enums' import type { ProjectRole, Role, TabType } from './enums'
@ -70,3 +70,18 @@ export interface TabItem {
viewTitle?: string viewTitle?: string
viewId?: string viewId?: string
} }
export interface SharedViewMeta extends Record<string, any> {
surveyMode?: boolean
withTheme?: boolean
theme?: Partial<ThemeConfig>
allowCSVDownload?: boolean
}
export interface SharedView {
uuid?: string
id: string
password?: string
type?: ViewTypes
meta: SharedViewMeta
}

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

@ -21,6 +21,7 @@ import {
useRouter, useRouter,
useSidebar, useSidebar,
useTabs, useTabs,
useTheme,
useUIPermission, useUIPermission,
} from '#imports' } from '#imports'
import { TabType } from '~/lib' import { TabType } from '~/lib'
@ -29,6 +30,8 @@ definePageMeta({
hideHeader: true, hideHeader: true,
}) })
const { theme } = useTheme()
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@ -336,6 +339,7 @@ onBeforeUnmount(reset)
<template #expandIcon></template> <template #expandIcon></template>
<LazyGeneralColorPicker <LazyGeneralColorPicker
:model-value="theme.primaryColor"
:colors="projectThemeColors" :colors="projectThemeColors"
:row-size="9" :row-size="9"
:advanced="false" :advanced="false"

84
packages/nc-gui/pages/[projectType]/form/[viewId].vue

@ -4,14 +4,18 @@ import {
IsPublicInj, IsPublicInj,
MetaInj, MetaInj,
ReloadViewDataHookInj, ReloadViewDataHookInj,
createError,
createEventHook, createEventHook,
definePageMeta, definePageMeta,
navigateTo,
provide, provide,
reactive,
ref, ref,
useProvideSharedFormStore, useProvideSharedFormStore,
useProvideSmartsheetStore, useProvideSmartsheetStore,
useRoute, useRoute,
useSidebar, useSidebar,
watch,
} from '#imports' } from '#imports'
definePageMeta({ definePageMeta({
@ -22,7 +26,9 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
const route = useRoute() const route = useRoute()
const { loadSharedView, sharedView, meta, notFound } = useProvideSharedFormStore(route.params.viewId as string) const { loadSharedView, sharedView, meta, notFound, password, passwordDlg, passwordError } = useProvideSharedFormStore(
route.params.viewId as string,
)
await loadSharedView() await loadSharedView()
@ -33,11 +39,85 @@ if (!notFound.value) {
provide(IsFormInj, ref(true)) provide(IsFormInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true) useProvideSmartsheetStore(sharedView, meta, true)
} else {
navigateTo('/error/404')
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' })
} }
const form = reactive({
password: '',
})
watch(
() => form.password,
() => {
password.value = form.password
},
)
</script> </script>
<template> <template>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage v-if="!passwordDlg" />
<a-modal
v-model:visible="passwordDlg"
:closable="false"
width="min(100%, 450px)"
centered
:footer="null"
:mask-closable="false"
wrap-class-name="nc-modal-shared-form-password-dlg"
@close="passwordDlg = false"
>
<div class="w-full flex flex-col gap-4">
<!-- todo: i18n -->
<h2 class="text-xl font-semibold">This shared view is protected</h2>
<a-form layout="vertical" no-style :model="form" @finish="loadSharedView">
<a-form-item name="password" :rules="[{ required: true, message: $t('msg.error.signUpRules.passwdRequired') }]">
<a-input-password v-model:value="form.password" size="large" :placeholder="$t('msg.info.signUp.enterPassword')" />
</a-form-item>
<Transition name="layout">
<div v-if="passwordError" class="mb-2 text-sm text-red-500">{{ passwordError }}</div>
</Transition>
<!-- Unlock -->
<button type="submit" class="mt-4 scaling-btn bg-opacity-100">{{ $t('general.unlock') }}</button>
</a-form>
</div>
</a-modal>
</NuxtLayout> </NuxtLayout>
</template> </template>
<style lang="scss" scoped>
:deep(.nc-cell-attachment) {
@apply p-0;
.nc-attachment-cell {
@apply px-4 min-h-[75px] w-full h-full;
.nc-attachment {
@apply md:(w-[50px] h-[50px]) lg:(w-[75px] h-[75px]) min-h-[50px] min-w-[50px];
}
.nc-attachment-cell-dropzone {
@apply rounded bg-gray-400/75;
}
}
}
.nc-modal-shared-form-password-dlg {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

233
packages/nc-gui/pages/[projectType]/form/[viewId]/index.vue

@ -1,166 +1,103 @@
<script setup lang="ts"> <script setup lang="ts">
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk' import { navigateTo, useDark, useRoute, useRouter, useSharedFormStoreOrThrow, useTheme, watch } from '#imports'
import { useSharedFormStoreOrThrow } from '#imports'
const { sharedViewMeta } = useSharedFormStoreOrThrow()
const {
sharedFormView, const isDark = useDark()
submitForm,
v$, const { setTheme } = useTheme()
formState,
notFound, const route = useRoute()
formColumns,
submitted, const router = useRouter()
secondsRemain,
passwordDlg, watch(
password, () => sharedViewMeta.value.withTheme,
loadSharedView, (hasTheme) => {
} = useSharedFormStoreOrThrow() if (hasTheme && sharedViewMeta.value.theme) setTheme(sharedViewMeta.value.theme)
},
function isRequired(_columnObj: Record<string, any>, required = false) { { immediate: true },
let columnObj = _columnObj )
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
columnObj.colOptions.type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find((c) => c.id === columnObj.colOptions.fk_child_column_id) as Record<string, any>
}
return !!(required || (columnObj && columnObj.rqd && !columnObj.cdf)) const onClick = () => {
isDark.value = !isDark.value
} }
const shouldRedirect = (to: string) => {
if (sharedViewMeta.value.surveyMode) {
if (!to.includes('survey')) navigateTo(`/nc/form/${route.params.viewId}/survey`)
} else {
if (to.includes('survey')) navigateTo(`/nc/form/${route.params.viewId}`)
}
}
shouldRedirect(route.name as string)
router.afterEach((to) => shouldRedirect(to.name as string))
</script> </script>
<template> <template>
<div class="nc-form-view md:bg-primary bg-opacity-5 min-h-full flex flex-col nc-form-signin py-15"> <div
class="scrollbar-thin-dull overflow-y-auto overflow-x-hidden flex flex-col color-transition nc-form-view relative bg-primary bg-opacity-10 dark:(bg-slate-900) h-full min-h-[600px]"
>
<NuxtPage />
<div <div
class="bg-white relative flex flex-col justify-center gap-2 w-full lg:max-w-1/2 max-w-500px m-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)" 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"
> >
<template v-if="sharedFormView"> <Transition name="slide-left" duration="250" mode="out-in">
<img width="90" height="90" alt="NocoDB" class="mx-auto" src="~/assets/img/icons/512x512.png" /> <MaterialSymbolsDarkModeOutline v-if="isDark" />
<MaterialSymbolsLightModeOutline v-else />
<h1 class="prose-2xl font-bold self-center my-4">{{ sharedFormView?.heading }}</h1> </Transition>
<h2 v-if="sharedFormView?.subheading" class="prose-lg text-gray-500 self-center">{{ sharedFormView.subheading }}</h2>
<a-alert v-if="notFound" type="warning" class="my-4 text-center" message="Not found" />
<template v-else-if="submitted">
<div class="flex justify-center">
<div v-if="sharedFormView" class="min-w-350px mt-3">
<a-alert
type="success"
class="my-4 text-center"
outlined
:message="sharedFormView.success_msg || 'Successfully submitted form data'"
/>
<p v-if="sharedFormView.show_blank_form" class="text-xs text-gray-500 text-center my-4">
New form will be loaded after {{ secondsRemain }} seconds
</p>
<div v-if="sharedFormView.submit_another_form" class="text-center">
<a-button type="primary" @click="submitted = false"> Submit Another Form</a-button>
</div>
</div>
</div>
</template>
<template v-else-if="sharedFormView">
<div class="nc-form-wrapper">
<div class="nc-form h-full max-w-3/4 mx-auto">
<div v-for="(field, index) in formColumns" :key="index" class="flex flex-col my-6 gap-2">
<div class="flex nc-form-column-label">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
</div>
<div v-if="isVirtualCol(field)" class="mt-0">
<LazySmartsheetVirtualCell
class="mt-0 nc-input"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field"
/>
<div v-if="field.description" class="text-gray-500 text-[10px] mb-2 ml-1">{{ field.description }}</div>
<template v-if="v$.virtual.$dirty && v$.virtual?.[field.title]">
<div v-for="error of v$.virtual[field.title].$errors" :key="error" class="text-xs text-red-500">
{{ error.$message }}
</div>
</template>
</div>
<div v-else class="mt-0">
<LazySmartsheetCell
v-model="formState[field.title]"
class="nc-input"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
<div v-if="field.description" class="text-gray-500 text-[10px] mb-2 ml-1">{{ field.description }}</div>
<template v-if="v$.localState.$dirty && v$.localState?.[field.title]">
<div v-for="error of v$.localState[field.title].$errors" :key="error" class="text-xs text-red-500">
{{ error.$message }}
</div>
</template>
</div>
</div>
<div class="text-center my-9">
<button type="submit" class="scaling-btn bg-opacity-100" @click="submitForm">
{{ $t('general.submit') }}
</button>
</div>
</div>
</div>
</template>
</template>
<a-modal
v-model:visible="passwordDlg"
:closable="false"
width="28rem"
centered
:footer="null"
:mask-closable="false"
wrap-class-name="nc-modal-shared-form-password-dlg"
@close="passwordDlg = false"
>
<div class="w-full flex flex-col">
<a-typography-title :level="4">This shared view is protected</a-typography-title>
<a-form ref="formRef" :model="{ password }" class="mt-2" @finish="loadSharedView">
<a-form-item name="password" :rules="[{ required: true, message: $t('msg.error.signUpRules.passwdRequired') }]">
<a-input-password v-model:value="password" :placeholder="$t('msg.info.signUp.enterPassword')" />
</a-form-item>
<!-- Unlock -->
<a-button type="primary" html-type="submit">{{ $t('general.unlock') }}</a-button>
</a-form>
</div>
</a-modal>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss">
html,
body,
h1,
h2,
h3,
h4,
h5,
h6,
p {
@apply dark:text-white color-transition;
}
.nc-form-view { .nc-form-view {
.nc-input { .nc-input {
@apply w-full rounded p-2 min-h-[40px] flex items-center border-solid border-1 border-primary; @apply w-full rounded p-2 min-h-[40px] flex items-center border-solid border-1 border-gray-300 dark:border-slate-200;
input,
&.nc-virtual-cell,
> div {
@apply bg-white dark:(bg-slate-500 text-white);
.ant-btn {
@apply dark:(bg-slate-300);
}
.chip {
@apply dark:(bg-slate-700 text-white);
}
}
}
}
.nc-cell {
@apply bg-white dark:bg-slate-500;
.nc-attachment-cell > div {
@apply dark:(bg-slate-100);
}
}
.nc-form-column-label {
> * {
@apply dark:text-slate-300;
} }
} }
</style> </style>

125
packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue

@ -0,0 +1,125 @@
<script lang="ts" setup>
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { useSharedFormStoreOrThrow } from '#imports'
const { sharedFormView, submitForm, v$, formState, notFound, formColumns, submitted, secondsRemain, isLoading } =
useSharedFormStoreOrThrow()
function isRequired(_columnObj: Record<string, any>, required = false) {
let columnObj = _columnObj
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
columnObj.colOptions.type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find((c) => c.id === columnObj.colOptions.fk_child_column_id) as Record<string, any>
}
return !!(required || (columnObj && columnObj.rqd && !columnObj.cdf))
}
</script>
<template>
<div>
<div
class="color-transition relative flex flex-col justify-center gap-2 w-full max-w-[max(33%,600px)] m-auto py-4 pb-8 px-16 md:(bg-white dark:bg-slate-700 rounded-lg border-1 border-gray-200 shadow-xl) mt-12"
>
<template v-if="sharedFormView">
<h1 class="prose-2xl font-bold self-center my-4">{{ sharedFormView.heading }}</h1>
<h2 v-if="sharedFormView.subheading" class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6">
{{ sharedFormView.subheading }}
</h2>
<a-alert v-if="notFound" type="warning" class="my-4 text-center" message="Not found" />
<template v-else-if="submitted">
<div class="flex justify-center">
<div v-if="sharedFormView" class="min-w-350px mt-3">
<a-alert
type="success"
class="my-4 text-center"
outlined
:message="sharedFormView.success_msg || 'Successfully submitted form data'"
/>
<p v-if="sharedFormView.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4">
New form will be loaded after {{ secondsRemain }} seconds
</p>
<div v-if="sharedFormView.submit_another_form" class="text-center">
<a-button type="primary" @click="submitted = false"> Submit Another Form</a-button>
</div>
</div>
</div>
</template>
<template v-else>
<GeneralOverlay class="bg-gray-400/75" :model-value="isLoading" inline transition>
<div class="w-full h-full flex items-center justify-center">
<a-spin size="large" />
</div>
</GeneralOverlay>
<div class="nc-form-wrapper">
<div class="nc-form h-full">
<div class="flex flex-col gap-6">
<div v-for="(field, index) in formColumns" :key="index" class="flex flex-col gap-2">
<div class="flex nc-form-column-label">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
</div>
<div>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
class="mt-0 nc-input"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
{{ field.description }}
</div>
</div>
</div>
</div>
<div class="text-center mt-4">
<button type="submit" class="uppercase scaling-btn prose-sm" @click="submitForm">
{{ $t('general.submit') }}
</button>
</div>
</div>
</div>
</template>
</template>
</div>
<GeneralPoweredBy />
</div>
</template>

346
packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue

@ -0,0 +1,346 @@
<script lang="ts" setup>
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { SwipeDirection, breakpointsTailwind } from '@vueuse/core'
import {
DropZoneRef,
computed,
onKeyStroke,
onMounted,
provide,
ref,
useBreakpoints,
usePointerSwipe,
useSharedFormStoreOrThrow,
useStepper,
} from '#imports'
enum TransitionDirection {
Left = 'left',
Right = 'right',
}
const { md } = useBreakpoints(breakpointsTailwind)
const { v$, formState, formColumns, submitForm, submitted, secondsRemain, sharedFormView, onReset } = useSharedFormStoreOrThrow()
const isTransitioning = ref(false)
const transitionName = ref<TransitionDirection>(TransitionDirection.Left)
const el = ref<HTMLDivElement>()
provide(DropZoneRef, el)
const steps = computed(() => {
if (!formColumns.value) return []
return formColumns.value.reduce<string[]>((acc, column) => {
const title = column.label || column.title
if (!title) return acc
acc.push(title)
return acc
}, [])
})
const { index, goToPrevious, goToNext, isFirst, isLast, goTo } = useStepper(steps)
const field = computed(() => formColumns.value?.[index.value])
function isRequired(column: ColumnType, required = false) {
let columnObj = column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
(columnObj.colOptions as { type: RelationTypes }).type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find(
(c) => c.id === (columnObj.colOptions as LinkToAnotherRecordType).fk_child_column_id,
) as ColumnType
}
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
function transition(direction: TransitionDirection) {
isTransitioning.value = true
transitionName.value = direction
setTimeout(() => {
transitionName.value =
transitionName.value === TransitionDirection.Left ? TransitionDirection.Right : TransitionDirection.Left
}, 500)
setTimeout(() => {
isTransitioning.value = false
setTimeout(focusInput, 100)
}, 1000)
}
async function goNext() {
if (isLast.value) return
if (!field.value || !field.value.title) return
const validationField = v$.value.localState[field.value.title]
if (validationField) {
const isValid = await validationField.$validate()
if (!isValid) return
}
transition(TransitionDirection.Left)
goToNext()
}
async function goPrevious() {
if (isFirst.value) return
transition(TransitionDirection.Right)
goToPrevious()
}
function focusInput() {
if (document && typeof document !== 'undefined') {
const inputEl =
(document.querySelector('.nc-cell input') as HTMLInputElement) ||
(document.querySelector('.nc-cell textarea') as HTMLTextAreaElement)
if (inputEl) {
inputEl.select()
inputEl.focus()
}
}
}
function resetForm() {
v$.value.$reset()
submitted.value = false
transition(TransitionDirection.Right)
goTo(steps.value[0])
}
onReset(resetForm)
onKeyStroke(['ArrowLeft', 'ArrowDown'], goPrevious)
onKeyStroke(['ArrowRight', 'ArrowUp', 'Enter', 'Space'], goNext)
onMounted(() => {
focusInput()
if (!md.value) {
const { direction } = usePointerSwipe(el, {
onSwipe: () => {
if (isTransitioning.value) return
if (direction.value === SwipeDirection.LEFT) {
goNext()
} else if (direction.value === SwipeDirection.RIGHT) {
goPrevious()
}
},
})
}
})
</script>
<template>
<div ref="el" class="pt-8 md:p-0 w-full h-full flex flex-col">
<div
v-if="sharedFormView"
style="height: max(40vh, 250px); min-height: 250px"
class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end"
>
<div class="px-4 md:px-0 flex flex-col justify-end">
<h1 class="prose-2xl font-bold self-center my-4">{{ sharedFormView.heading }}</h1>
<h2
v-if="sharedFormView.subheading && sharedFormView.subheading !== ''"
class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6"
>
{{ sharedFormView?.subheading }}
</h2>
</div>
</div>
<div class="h-full w-full flex items-center px-4 md:px-0">
<Transition :name="`slide-${transitionName}`" :duration="1000" mode="out-in">
<div
ref="el"
:key="field.title"
class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto"
>
<div v-if="field && !submitted" class="flex flex-col gap-2">
<div class="flex nc-form-column-label">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
</div>
<div>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
class="mt-0 nc-input"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input"
:class="`nc-form-input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
<div class="block text-[14px]">
{{ field.description }}
</div>
</div>
</div>
</div>
<div class="ml-1 mt-4 flex w-full text-lg">
<div class="flex-1 flex justify-center">
<div v-if="isLast && !submitted && !v$.$invalid" class="text-center my-4">
<button type="submit" class="uppercase scaling-btn prose-sm" @click="submitForm">
{{ $t('general.submit') }}
</button>
</div>
<div v-else-if="!submitted" class="flex items-center gap-3">
<a-tooltip
:title="v$.localState[field.title]?.$error ? v$.localState[field.title].$errors[0].$message : 'Go to next'"
:mouse-enter-delay="0.25"
:mouse-leave-delay="0"
>
<button
class="bg-opacity-100 scaling-btn flex items-center gap-1"
:class="v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : ''"
@click="goNext"
>
<Transition name="fade">
<span v-if="!v$.localState[field.title]?.$error" class="uppercase text-white">Ok</span>
</Transition>
<Transition name="slide-right" mode="out-in">
<MdiCloseCircleOutline v-if="v$.localState[field.title]?.$error" class="text-red-500 md:text-md" />
<MdiCheck v-else class="text-white md:text-md" />
</Transition>
</button>
</a-tooltip>
<!-- todo: i18n -->
<div class="hidden md:flex text-sm text-gray-500 items-center gap-1">
Press Enter <MaterialSymbolsKeyboardReturn class="mt-1" />
</div>
</div>
</div>
</div>
<Transition name="slide-left">
<div v-if="submitted" class="flex flex-col justify-center items-center text-center">
<div class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded">
<template v-if="sharedFormView?.success_msg">
{{ sharedFormView?.success_msg }}
</template>
<template v-else>
<div class="flex flex-col gap-1">
<div>Thank you!</div>
<div>You have successfully submitted the form data.</div>
</div>
</template>
</div>
<div v-if="sharedFormView" class="mt-3">
<p v-if="sharedFormView?.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4">
New form will be loaded after {{ secondsRemain }} seconds
</p>
<div v-if="sharedFormView?.submit_another_form" class="text-center">
<button type="button" class="scaling-btn bg-opacity-100" @click="resetForm">Submit Another Form</button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
<template v-if="!submitted">
<div class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200">
{{ index + 1 }} / {{ formColumns?.length }}
</div>
</template>
<div class="relative flex w-full items-end">
<Transition name="fade">
<div
v-if="!submitted"
class="color-transition shadow-sm absolute bottom-18 right-1/2 transform translate-x-[50%] md:bottom-4 md:(right-12 transform-none) flex items-center bg-white border dark:bg-slate-500 rounded divide-x-1"
>
<a-tooltip :title="isFirst ? '' : 'Go to previous'" :mouse-enter-delay="0.25" :mouse-leave-delay="0">
<button class="p-0.5 flex items-center group color-transition" @click="goPrevious">
<MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" />
</button>
</a-tooltip>
<a-tooltip
:title="v$.localState[field.title]?.$error ? '' : 'Go to next'"
:mouse-enter-delay="0.25"
:mouse-leave-delay="0"
>
<button class="p-0.5 flex items-center group color-transition" @click="goNext">
<MdiChevronRight
:class="isLast || v$.localState[field.title]?.$error ? 'text-gray-300' : 'group-hover:text-accent'"
class="text-2xl md:text-md"
/>
</button>
</a-tooltip>
</div>
</Transition>
<GeneralPoweredBy />
</div>
</div>
</template>
<style lang="scss">
:global(html, body) {
@apply overscroll-x-none;
}
.nc-form-column-label {
> * {
@apply !prose-lg;
}
.nc-icon {
@apply mr-2;
}
}
</style>

4
packages/nc-gui/pages/error/404.vue

@ -8,8 +8,8 @@ definePageMeta({
</script> </script>
<template> <template>
<div class="w-full h-[300px] flex justify-center items-center text-4xl"> <div class="bg-primary bg-opacity-5 w-full h-full flex flex-col justify-center items-center text-4xl gap-12">
<div class="text-gray-400 flex gap-2 items-center"> <div class="text-gray-400 flex gap-4 items-center">
<MdiWarning /> <MdiWarning />
Page Not Found Page Not Found
</div> </div>

8
packages/nc-gui/pages/index/index.vue

@ -9,9 +9,9 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
<template> <template>
<NuxtLayout> <NuxtLayout>
<div <div
class="min-h-[calc(100vh_-_var(--header-height))] bg-primary bg-opacity-5 flex flex-wrap justify-between xl:flex-nowrap gap-6 py-6 px-4 pt-65px md:(px-12)" class="min-h-[calc(100vh_-_var(--header-height))] bg-primary bg-opacity-5 flex flex-wrap justify-between xl:flex-nowrap gap-6 py-6 px-4 md:(px-12 pt-65px)"
> >
<div class="flex-1 justify-end hidden xl:(flex)"> <div class="hidden xl:(flex)">
<div> <div>
<LazyGeneralSponsors /> <LazyGeneralSponsors />
</div> </div>
@ -21,14 +21,14 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
<NuxtPage /> <NuxtPage />
</div> </div>
<div class="flex flex-1 justify-between gap-6 lg:block"> <div class="flex-1 flex gap-6 flex-col justify-center items-center md:(flex-row justify-between items-start)">
<template v-if="route.name === 'index-index'"> <template v-if="route.name === 'index-index'">
<TransitionGroup name="page" mode="out-in"> <TransitionGroup name="page" mode="out-in">
<div key="social-card"> <div key="social-card">
<LazyGeneralSocialCard /> <LazyGeneralSocialCard />
</div> </div>
<div key="sponsors" class="block mt-0 lg:(!mt-6) xl:hidden"> <div key="sponsors" class="inline-block xl:hidden">
<LazyGeneralSponsors /> <LazyGeneralSponsors />
</div> </div>
</TransitionGroup> </TransitionGroup>

38
packages/nc-gui/pages/index/index/index.vue

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk' import type { ProjectType } from 'nocodb-sdk'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import { breakpointsTailwind } from '@vueuse/core'
import { import {
Empty, Empty,
Modal, Modal,
@ -14,6 +15,7 @@ import {
ref, ref,
themeV2Colors, themeV2Colors,
useApi, useApi,
useBreakpoints,
useGlobal, useGlobal,
useNuxtApp, useNuxtApp,
useUIPermission, useUIPermission,
@ -29,6 +31,8 @@ const { api, isLoading } = useApi()
const { isUIAllowed } = useUIPermission() const { isUIAllowed } = useUIPermission()
const { md } = useBreakpoints(breakpointsTailwind)
const filterQuery = ref('') const filterQuery = ref('')
const projects = ref<ProjectType[]>() const projects = ref<ProjectType[]>()
@ -131,12 +135,19 @@ onBeforeMount(loadProjects)
<template> <template>
<div class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"> <div class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)">
<h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4"> <h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4">
<!-- My Projects -->
<span class="text-4xl nc-project-page-title">{{ $t('title.myProject') }}</span> <span class="text-4xl nc-project-page-title">{{ $t('title.myProject') }}</span>
</h1>
<div class="flex flex-wrap gap-2 mb-6">
<a-input-search
v-model:value="filterQuery"
class="max-w-[250px] nc-project-page-search rounded"
:placeholder="$t('activity.searchProject')"
/>
<a-tooltip title="Reload projects"> <a-tooltip title="Reload projects">
<span <div
class="transition-all duration-200 h-full flex items-center group hover:ring active:(ring ring-accent) rounded-full mt-1" class="transition-all duration-200 h-full flex-0 flex items-center group hover:ring active:(ring ring-accent) rounded-full mt-1"
:class="isLoading ? 'animate-spin ring ring-gray-200' : ''" :class="isLoading ? 'animate-spin ring ring-gray-200' : ''"
> >
<MdiRefresh <MdiRefresh
@ -145,21 +156,13 @@ onBeforeMount(loadProjects)
:class="isLoading ? '!text-primary' : ''" :class="isLoading ? '!text-primary' : ''"
@click="loadProjects" @click="loadProjects"
/> />
</span> </div>
</a-tooltip> </a-tooltip>
</h1>
<div class="flex mb-6">
<a-input-search
v-model:value="filterQuery"
class="max-w-[250px] nc-project-page-search rounded"
:placeholder="$t('activity.searchProject')"
/>
<div class="flex-1" /> <div class="flex-1" />
<a-dropdown v-if="isUIAllowed('projectCreate', true)" :trigger="['click']" overlay-class-name="nc-dropdown-create-project"> <a-dropdown v-if="isUIAllowed('projectCreate', true)" :trigger="['click']" overlay-class-name="nc-dropdown-create-project">
<button class="nc-new-project-menu"> <button class="nc-new-project-menu mt-4 md:mt-0">
<span class="flex items-center w-full"> <span class="flex items-center w-full">
{{ $t('title.newProj') }} {{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" /> <MdiMenuDown class="menu-icon" />
@ -201,7 +204,13 @@ onBeforeMount(loadProjects)
<a-skeleton /> <a-skeleton />
</div> </div>
<a-table v-else :custom-row="customRow" :data-source="filteredProjects" :pagination="{ position: ['bottomCenter'] }"> <a-table
v-else
:custom-row="customRow"
:data-source="filteredProjects"
:pagination="{ position: ['bottomCenter'] }"
:table-layout="md ? 'auto' : 'fixed'"
>
<template #emptyText> <template #emptyText>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template> </template>
@ -228,6 +237,7 @@ onBeforeMount(loadProjects)
<template #expandIcon></template> <template #expandIcon></template>
<LazyGeneralColorPicker <LazyGeneralColorPicker
:model-value="getProjectPrimary(record)"
:colors="projectThemeColors" :colors="projectThemeColors"
:row-size="9" :row-size="9"
:advanced="false" :advanced="false"

36
scripts/cypress/integration/common/4b_table_view_share.js

@ -13,35 +13,17 @@ const generateLinkWithPwd = () => {
.should("be.visible"); .should("be.visible");
// enable checkbox & feed pwd, save // enable checkbox & feed pwd, save
cy.getActiveModal(".nc-modal-share-view") cy.get('[data-cy="nc-modal-share-view__with-password"]').click();
.find(".ant-collapse") cy.get('[data-cy="nc-modal-share-view__password"]').clear().type('1')
.should("exist") cy.get('[data-cy="nc-modal-share-view__save-password"]').click();
.click(); cy.toastWait("Successfully updated");
cy.getActiveModal(".nc-modal-share-view")
.find(".ant-checkbox-input")
.should("exist")
.first()
.then(($el) => {
if (!$el.prop("checked")) {
cy.wrap($el).click({ force: true });
cy.getActiveModal(".nc-modal-share-view")
.find('input[type="password"]')
.clear()
.type("1");
cy.getActiveModal(".nc-modal-share-view")
.find('button:contains("Save password")')
.click();
cy.toastWait("Successfully updated");
}
});
// copy link text, visit URL // copy link text, visit URL
cy.getActiveModal(".nc-modal-share-view") cy.get('[data-cy="nc-modal-share-view__link"]').then(($el) => {
.find(".nc-share-link-box") linkText = $el.text();
.then(($obj) => { // todo: visit url?
linkText = $obj.text().trim(); cy.log(linkText);
cy.log(linkText); })
});
}; };
export const genTest = (apiType, dbType) => { export const genTest = (apiType, dbType) => {

6
scripts/cypress/support/commands.js

@ -174,7 +174,7 @@ Cypress.Commands.add("gridWait", (rc) => {
if (rc != 0) { if (rc != 0) {
cy.get(".nc-grid-row").should("have.length", rc); cy.get(".nc-grid-row").should("have.length", rc);
} }
}) });
// tn: table name // tn: table name
// rc: row count. validate row count if rc!=0 // rc: row count. validate row count if rc!=0
@ -332,7 +332,9 @@ Cypress.Commands.add("createTable", (name) => {
cy.getActiveModal(".nc-modal-table-create") cy.getActiveModal(".nc-modal-table-create")
.find("button.ant-btn-primary:visible") .find("button.ant-btn-primary:visible")
.click(); .click();
cy.get(".xc-row-table.nc-grid").should("exist");
cy.gridWait(0);
cy.url().should("contain", `table/${name}`); cy.url().should("contain", `table/${name}`);
cy.get(`.nc-project-tree-tbl-${name}`).should("exist"); cy.get(`.nc-project-tree-tbl-${name}`).should("exist");
}); });

Loading…
Cancel
Save