Browse Source

Merge branch 'develop' into feat/kanban-view

pull/3563/head
Wing-Kam Wong 2 years ago
parent
commit
4e3cd99883
  1. 4
      packages/nc-gui/assets/style.scss
  2. 4
      packages/nc-gui/components.d.ts
  3. 14
      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. 18
      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. 6
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  17. 12
      packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue
  18. 22
      packages/nc-gui/components/smartsheet/toolbar/Reload.vue
  19. 168
      packages/nc-gui/components/smartsheet/toolbar/ShareView.vue
  20. 2
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  21. 22
      packages/nc-gui/composables/useSharedFormViewStore.ts
  22. 6
      packages/nc-gui/composables/useTheme/index.ts
  23. 1
      packages/nc-gui/context/index.ts
  24. 1
      packages/nc-gui/lang/ar.json
  25. 1
      packages/nc-gui/lang/bn_IN.json
  26. 1
      packages/nc-gui/lang/da.json
  27. 1
      packages/nc-gui/lang/de.json
  28. 1
      packages/nc-gui/lang/en.json
  29. 1
      packages/nc-gui/lang/es.json
  30. 1
      packages/nc-gui/lang/fa.json
  31. 1
      packages/nc-gui/lang/fi.json
  32. 1
      packages/nc-gui/lang/fr.json
  33. 1
      packages/nc-gui/lang/he.json
  34. 1
      packages/nc-gui/lang/hi.json
  35. 1
      packages/nc-gui/lang/hr.json
  36. 1
      packages/nc-gui/lang/id.json
  37. 1
      packages/nc-gui/lang/it.json
  38. 1
      packages/nc-gui/lang/ja.json
  39. 1
      packages/nc-gui/lang/ko.json
  40. 1
      packages/nc-gui/lang/lv.json
  41. 1
      packages/nc-gui/lang/nl.json
  42. 1
      packages/nc-gui/lang/no.json
  43. 1
      packages/nc-gui/lang/pl.json
  44. 1
      packages/nc-gui/lang/pt.json
  45. 1
      packages/nc-gui/lang/pt_BR.json
  46. 1
      packages/nc-gui/lang/ru.json
  47. 1
      packages/nc-gui/lang/sl.json
  48. 1
      packages/nc-gui/lang/sv.json
  49. 1
      packages/nc-gui/lang/th.json
  50. 1
      packages/nc-gui/lang/tr.json
  51. 1
      packages/nc-gui/lang/uk.json
  52. 1
      packages/nc-gui/lang/vi.json
  53. 1
      packages/nc-gui/lang/zh-Hans.json
  54. 1
      packages/nc-gui/lang/zh-Hant.json
  55. 17
      packages/nc-gui/lib/types.ts
  56. 4
      packages/nc-gui/pages/[projectType]/[projectId]/index.vue
  57. 84
      packages/nc-gui/pages/[projectType]/form/[viewId].vue
  58. 233
      packages/nc-gui/pages/[projectType]/form/[viewId]/index.vue
  59. 125
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/index.vue
  60. 346
      packages/nc-gui/pages/[projectType]/form/[viewId]/index/survey.vue
  61. 4
      packages/nc-gui/pages/error/404.vue
  62. 8
      packages/nc-gui/pages/index/index.vue
  63. 43
      packages/nc-gui/pages/index/index/index.vue
  64. 14
      packages/nc-gui/pages/signin.vue
  65. 4
      packages/nc-gui/pages/signup/[[token]].vue
  66. 9
      packages/nocodb/src/lib/meta/api/projectApis.ts
  67. 25
      packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts
  68. 5
      scripts/cypress/integration/common/1b_table_column_operations.js
  69. 2
      scripts/cypress/integration/common/3a_filter_sort_fields_operations.js
  70. 3
      scripts/cypress/integration/common/3e_duration_column.js
  71. 3
      scripts/cypress/integration/common/3f_link_to_another_record.js
  72. 36
      scripts/cypress/integration/common/4b_table_view_share.js
  73. 2
      scripts/cypress/integration/common/4f_grid_view_share.js
  74. 2
      scripts/cypress/integration/common/4f_pg_grid_view_share.js
  75. 3
      scripts/cypress/integration/common/8a_webhook.js
  76. 6
      scripts/cypress/support/commands.js
  77. 15
      scripts/cypress/support/page_objects/mainPage.js

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

@ -277,3 +277,7 @@ a {
transform: scale(75%);
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']
MaterialSymbolsChevronRightRounded: typeof import('~icons/material-symbols/chevron-right-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']
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']
MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default']
@ -131,6 +134,7 @@ declare module '@vue/runtime-core' {
MdiCheck: typeof import('~icons/mdi/check')['default']
MdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
MdiChevronLeft: typeof import('~icons/mdi/chevron-left')['default']
MdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
MdiClose: typeof import('~icons/mdi/close')['default']
MdiCloseBox: typeof import('~icons/mdi/close-box')['default']
MdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']

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

@ -28,9 +28,9 @@ const column = inject(ColumnInj)!
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 vModel = computed({
@ -72,14 +72,8 @@ watch(
</script>
<template>
<div class="flex flex-row items-center justify-between">
<input
v-if="editEnabled"
:ref="focus"
v-model="vModel"
class="outline-none text-sm w-full px-2"
@blur="editEnabled = false"
/>
<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 px-2" @blur="editEnabled = false" />
<nuxt-link
v-else-if="isValid && !cellUrlOptions?.overlay"

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

@ -55,7 +55,7 @@ onClickOutside(carouselRef, () => {
</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)"
>
<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 { useSortable } from './sort'
import {
IsFormInj,
DropZoneRef,
IsGalleryInj,
IsKanbanInj,
inject,
@ -23,26 +23,30 @@ interface Props {
}
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 emits = defineEmits<Emits>()
const isForm = inject(IsFormInj, ref(false))
const isGallery = inject(IsGalleryInj, ref(false))
const isKanban = inject(IsKanbanInj, ref(false))
const dropZoneInjection = inject(DropZoneRef, ref())
const attachmentCellRef = ref<HTMLDivElement>()
const sortableRef = ref<HTMLDivElement>()
const { cellRefs } = useSmartsheetStoreOrThrow()!
const currentCellRef = ref<Element | undefined>(dropZoneInjection.value)
const { cellRefs, isSharedForm } = useSmartsheetStoreOrThrow()!
const {
isPublic,
isForm,
column,
modalVisible,
attachments,
@ -56,12 +60,12 @@ const {
storedFiles,
} = useProvideAttachmentCell(updateModelValue)
const currentCellRef = ref()
watch(
[() => rowIndex, isForm],
[() => rowIndex, isForm, attachmentCellRef],
() => {
if (!rowIndex && isForm.value && isGallery.value && isKanban.value) {
if (dropZoneInjection?.value) return
if (!rowIndex && (isForm.value || isGallery.value || isKanban.value)) {
currentCellRef.value = attachmentCellRef.value
} else {
nextTick(() => {
@ -86,7 +90,7 @@ const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, is
const { state: rowState } = useSmartsheetRowStoreOrThrow()
const { isOverDropZone } = useDropZone(currentCellRef, onDrop)
const { isOverDropZone } = useDropZone(currentCellRef as any, onDrop)
/** on new value, reparse our stored attachments */
watch(
@ -94,10 +98,20 @@ watch(
(nextModel) => {
if (nextModel) {
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) {
console.error(e)
attachments.value = []
if (isPublic.value && isForm.value) {
storedFiles.value = []
} else {
attachments.value = []
}
}
}
},
@ -105,8 +119,8 @@ watch(
)
/** updates attachments array for autosave */
function updateModelValue(data: string | Record<string, any>) {
emits('update:modelValue', typeof data !== 'string' ? JSON.stringify(data) : data)
function updateModelValue(data: string | Record<string, any>[]) {
emits('update:modelValue', data)
}
/** Close modal on escape press, disable dropzone as well */
@ -122,8 +136,6 @@ watch(
rowState.value[column.value!.title!] = storedFiles.value
},
)
const { isSharedForm } = useSmartsheetStoreOrThrow()
</script>
<template>
@ -138,7 +150,7 @@ const { isSharedForm } = useSmartsheetStoreOrThrow()
v-model="isOverDropZone"
inline
: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
</general-overlay>
@ -147,7 +159,7 @@ const { isSharedForm } = useSmartsheetStoreOrThrow()
<div
v-if="!isReadonly"
: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"
>
<MdiReload v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
@ -156,56 +168,66 @@ const { isSharedForm } = useSmartsheetStoreOrThrow()
<template #title> Click or drop a file into cell </template>
<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>
</a-tooltip>
</div>
<div v-else class="flex" />
<template v-if="visibleItems.length">
<div
ref="sortableRef"
: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
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]"
>
<template v-for="(item, i) of visibleItems" :key="item.url || item.title">
<a-tooltip placement="bottom">
<template #title>
<div class="text-center w-full">{{ item.title }}</div>
</template>
<LazyNuxtImg
v-if="isImage(item.title, item.mimetype ?? item.type) && (item.url || item.data)"
quality="75"
placeholder
:alt="item.title || `#${i}`"
:src="item.url || item.data"
class="ring-1 ring-gray-300 rounded max-h-[50px] max-w-[50px]"
@click="selectedImage = item"
/>
<template 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">
<LazyNuxtImg
quality="75"
placeholder
fit="cover"
:alt="item.title || `#${i}`"
: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>
</div>
</template>
</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 }" />
<a-tooltip v-else placement="bottom">
<template #title> View attachments </template>
<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"
/>
</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[]) {
if (!selectedFiles.length) return
if (isPublic.value) {
storedFiles.value.push(
...(await Promise.all<AttachmentProps>(
Array.from(selectedFiles).map(
(file) =>
new Promise<AttachmentProps>((resolve) => {
const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type }
if (isImage(file.name, (<any>file).mimetype ?? file.type)) {
const reader = new FileReader()
if (isPublic.value && isForm.value) {
const newFiles = await Promise.all<AttachmentProps>(
Array.from(selectedFiles).map(
(file) =>
new Promise<AttachmentProps>((resolve) => {
const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type }
reader.onload = (e) => {
res.data = e.target?.result
if (isImage(file.name, (<any>file).mimetype ?? file.type)) {
const reader = new FileReader()
resolve(res)
}
reader.onload = (e) => {
res.data = e.target?.result
reader.onerror = () => {
resolve(res)
}
resolve(res)
}
reader.readAsDataURL(file)
} else {
reader.onerror = () => {
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 = []
@ -132,7 +132,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
}
updateModelValue([...attachments.value, ...newAttachments])
updateModelValue(JSON.stringify([...attachments.value, ...newAttachments]))
}
/** 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))
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles))
@ -172,6 +172,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
attachments,
visibleItems,
isPublic,
isForm,
isReadonly,
meta,
column,

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

@ -11,7 +11,8 @@ const { width = 90, height = 90, animate = false } = defineProps<Props>()
<template>
<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">
<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">
<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 virtual = toRef(props, 'virtual', false)
const readOnly = toRef(props, 'readOnly', undefined)
provide(ColumnInj, column)
@ -125,7 +123,7 @@ const {
isPhoneNumber,
} = useColumn(column)
const syncAndNavigate = (dir: NavigateDir) => {
const syncAndNavigate = (dir: NavigateDir, e: KeyboardEvent) => {
if (isJSON.value) return
if (currentRow.value.rowMeta.changed) {
@ -133,19 +131,17 @@ const syncAndNavigate = (dir: NavigateDir) => {
currentRow.value.rowMeta.changed = false
}
emit('navigate', dir)
if (!isForm.value) e.stopImmediatePropagation()
}
</script>
<template>
<div
class="nc-cell w-full h-full"
:class="{ 'text-blue-600': isPrimary && !virtual && !isForm }"
@keydown.stop.left
@keydown.stop.right
@keydown.stop.up
@keydown.stop.down
@keydown.stop.enter.exact="syncAndNavigate(NavigateDir.NEXT)"
@keydown.stop.shift.enter.exact="syncAndNavigate(NavigateDir.PREV)"
class="nc-cell w-full"
:class="[`nc-cell-${(column?.uidt || 'default').toLowerCase()}`, { 'text-blue-600': isPrimary && !virtual && !isForm }]"
@keydown.enter.exact="syncAndNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="syncAndNavigate(NavigateDir.PREV, $event)"
>
<LazyCellTextArea v-if="isTextArea" 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()
function updateView() {
if ((formViewData.value?.subheading?.length || 0) > 255) {
message.error(t('msg.error.formDescriptionTooLong'))
return
}
updateFormView(formViewData.value)
}
const updateView = useDebounceFn(
() => {
if ((formViewData.value?.subheading?.length || 0) > 255) {
return message.error(t('msg.error.formDescriptionTooLong'))
}
updateFormView(formViewData.value)
},
300,
{ maxWait: 2000 },
)
async function submitForm() {
try {
await formRef.value?.validateFields()
} catch (e: any) {
e.errorFields.map((f: Record<string, any>) => message.error(f.errors.join(',')))
return
if (e.errorFields.length) return
}
const insertedRowData = await insertRow(formState)
@ -392,104 +396,106 @@ watch(view, (nextView) => {
<div class="text-center">{{ formViewData.success_msg || 'Successfully submitted form data' }}</div>
</template>
</a-alert>
<div v-if="formViewData.show_blank_form" class="text-gray-400 mt-4">
New form will be loaded after {{ secondsRemain }} seconds
</div>
<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>
</div>
</div>
</a-col>
</a-row>
<a-row v-else class="h-full flex">
<a-col
v-if="isEditable"
:span="8"
class="bg-[#f7f7f7] shadow-md p-5 h-full overflow-auto scrollbar-thin-primary nc-form-left-drawer"
>
<div class="flex">
<div class="flex flex-row flex-1 text-lg">
<span>
<!-- Fields -->
{{ $t('objects.fields') }}
</span>
<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">
<div class="flex flex-wrap gap-2">
<div class="flex-1 text-lg">
{{ $t('objects.fields') }}
</div>
<div class="flex flex-row">
<div class="cursor-pointer mr-2">
<span
v-if="hiddenColumns.length"
class="mr-2 nc-form-add-all"
style="border-bottom: 2px solid rgb(218, 218, 218)"
@click="addAllColumns"
>
<!-- Add all -->
{{ $t('general.addAll') }}
</span>
<span
v-if="localColumns.length"
class="ml-2 nc-form-remove-all"
style="border-bottom: 2px solid rgb(218, 218, 218)"
@click="removeAllColumns"
>
<!-- Remove all -->
{{ $t('general.removeAll') }}
</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button
v-if="hiddenColumns.length"
type="button"
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"
>
<!-- Add all -->
{{ $t('general.addAll') }}
</button>
<button
v-if="localColumns.length"
type="button"
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') }}
</button>
</div>
</div>
<Draggable
:list="hiddenColumns"
item-key="id"
draggable=".item"
group="form-inputs"
class="flex flex-col gap-2"
@start="drag = true"
@end="drag = false"
>
<template #item="{ element, index }">
<a-card
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"
@mousemove="moved = false"
@mouseup="handleMouseUp(element, index)"
>
<div class="flex">
<div class="flex flex-row flex-1">
<div class="flex flex-1">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(element)"
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
class="w-full"
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
/>
</div>
<div class="flex flex-row">
<MdiDragVertical class="flex flex-1" />
</div>
</div>
</a-card>
</template>
<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 -->
{{ $t('msg.info.dragDropHide') }}
</div>
<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">
<div class="flex items-center prose-sm justify-center text-gray-400">
<mdi-plus />
<button type="button" class="group w-full mt-2" @click.stop="showColumnDropdown = true">
<span class="flex items-center flex-wrap justify-center gap-2 prose-sm text-gray-400">
<MdiPlus class="color-transition transform group-hover:(text-accent scale-125)" />
<!-- Add new field to this table -->
{{ $t('activity.addField') }}
</div>
</a-button>
<span class="color-transition group-hover:text-primary break-words">
{{ $t('activity.addField') }}
</span>
</span>
</button>
<template #overlay>
<LazySmartsheetColumnEditOrAddProvider
<SmartsheetColumnEditOrAddProvider
v-if="showColumnDropdown"
@submit="submitCallback"
@cancel="showColumnDropdown = false"
@ -501,52 +507,60 @@ watch(view, (nextView) => {
</template>
</Draggable>
</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 -->
</div>
<a-card
class="m-0 rounded-b-0 p-4 border-none"
class="p-4 border-none"
:body-style="{
maxWidth: '700px',
maxWidth: 'max(50vw, 700px)',
margin: '0 auto',
marginTop: '-200px',
padding: '0px',
}"
>
<a-form ref="formRef" :model="formState" class="nc-form">
<a-card class="rounded m-2 py-10 px-5">
<a-form ref="formRef" :model="formState" class="nc-form" no-style>
<a-card class="!rounded !shadow !m-2 md:(!m-4) xl:(!m-8)" :body-style="{ paddingLeft: '0px', paddingRight: '0px' }">
<!-- Header -->
<a-form-item v-if="isEditable" class="m-0 gap-0 p-0">
<a-input
v-model:value="formViewData.heading"
class="w-full !font-bold !text-4xl"
size="large"
hide-details
placeholder="Form Title"
:bordered="false"
@blur="updateView"
@keydown.enter="updateView"
/>
</a-form-item>
<div v-else class="ml-3 w-full text-bold text-h3">{{ formViewData.heading }}</div>
<div v-if="isEditable" class="px-4 lg:px-12">
<a-form-item v-if="isEditable">
<a-input
v-model:value="formViewData.heading"
class="w-full !font-bold !text-4xl !border-0 !border-b-1 !border-dashed !rounded-none !border-gray-400"
:style="{ borderRightWidth: '0px !important' }"
size="large"
hide-details
placeholder="Form Title"
:bordered="false"
@blur="updateView"
@keydown.enter="updateView"
/>
</a-form-item>
</div>
<div v-else class="px-4 ml-3 w-full text-bold text-4xl">{{ formViewData.heading }}</div>
<!-- Sub Header -->
<a-form-item v-if="isEditable" class="m-0 gap-0 p-0">
<a-input
v-model:value="formViewData.subheading"
class="w-full"
size="large"
hide-details
:placeholder="$t('msg.info.formDesc')"
:bordered="false"
:disabled="!isEditable"
@blur="updateView"
@click="updateView"
/>
</a-form-item>
<div v-if="isEditable" class="px-4 lg:px-12">
<a-form-item>
<a-input
v-model:value="formViewData.subheading"
class="w-full !border-0 !border-b-1 !border-dashed !rounded-none !border-gray-400"
:style="{ borderRightWidth: '0px !important' }"
size="large"
hide-details
:placeholder="$t('msg.info.formDesc')"
:bordered="false"
:disabled="!isEditable"
@blur="updateView"
@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
ref="draggableRef"
@ -562,11 +576,11 @@ watch(view, (nextView) => {
>
<template #item="{ element, index }">
<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="[
`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"
@ -575,64 +589,53 @@ watch(view, (nextView) => {
v-if="isUIAllowed('editFormView') && !isRequired(element, element.required)"
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>
<template v-if="activeRow === element.title">
<div class="flex">
<div
class="flex flex-1 opacity-0 align-center gap-2"
:class="{ 'opacity-100': activeRow === element.title }"
<div v-if="activeRow === element.title" class="flex flex-col gap-3 mb-3">
<div class="flex gap-2 items-center">
<span
class="text-gray-500 mr-2 nc-form-input-required"
@click="
() => {
element.required = !element.required
updateColMeta(element)
}
"
>
<div class="flex flex-row">
<mdi-drag-vertical class="flex flex-1" />
</div>
<div class="items-center flex">
<span
class="text-xs text-gray-500 mr-2 nc-form-input-required"
@click="
() => {
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>
{{ $t('general.required') }}
</span>
<a-switch
v-model:checked="element.required"
v-e="['a:form-view:field:mark-required']"
size="small"
@change="updateColMeta(element)"
/>
</div>
<div class="my-3">
<a-form-item class="my-0 w-1/2 !mb-1">
<a-input
v-model:value="element.label"
size="small"
class="form-meta-input !bg-[#dbdbdb] nc-form-input-label"
:placeholder="$t('msg.info.formInput')"
@change="updateColMeta(element)"
>
</a-input>
</a-form-item>
<a-form-item class="mt-2 mb-0 w-1/2 !mb-1">
<a-input
v-model:value="element.description"
size="small"
class="form-meta-input !bg-[#dbdbdb] text-sm nc-form-input-help-text"
:placeholder="$t('msg.info.formHelpText')"
@change="updateColMeta(element)"
/>
</a-form-item>
</div>
</template>
<a-form-item class="my-0 w-1/2 !mb-1">
<a-input
v-model:value="element.label"
type="text"
class="form-meta-input nc-form-input-label"
:placeholder="$t('msg.info.formInput')"
@change="updateColMeta(element)"
>
</a-input>
</a-form-item>
<a-form-item class="mt-2 mb-0 w-1/2 !mb-1">
<a-input
v-model:value="element.description"
type="text"
class="form-meta-input text-sm nc-form-input-help-text"
:placeholder="$t('msg.info.formHelpText')"
@change="updateColMeta(element)"
/>
</a-form-item>
</div>
<div>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(element)"
@ -640,6 +643,7 @@ watch(view, (nextView) => {
:required="isRequired(element, element.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...element, title: element.label || element.title }"
@ -650,8 +654,8 @@ watch(view, (nextView) => {
<a-form-item
v-if="isVirtualCol(element)"
class="!m-0 gap-0 p-0"
:name="element.title"
class="!mb-0"
:rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]"
>
<LazySmartsheetVirtualCell
@ -666,8 +670,8 @@ watch(view, (nextView) => {
<a-form-item
v-else
class="!m-0 gap-0 p-0"
:name="element.title"
class="!mb-0"
:rules="[{ required: isRequired(element, element.required), message: `${element.title} is required` }]"
>
<LazySmartsheetCell
@ -680,7 +684,7 @@ watch(view, (nextView) => {
/>
</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>
</template>
@ -694,23 +698,24 @@ watch(view, (nextView) => {
</template>
</Draggable>
<div class="justify-center flex mt-10">
<a-button type="primary" class="flex items-center gap-2 nc-form-submit" size="large" @click="submitForm">
<!-- Submit -->
<div class="justify-center flex mt-6">
<button type="submit" class="uppercase scaling-btn nc-form-submit" @click="submitForm">
{{ $t('general.submit') }}
</a-button>
</button>
</div>
</a-card>
</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 -->
<div class="text-gray-500 mt-4 mb-2">
<div class="text-lg text-gray-700">
{{ $t('msg.info.afterFormSubmitted') }}
</div>
<!-- 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
v-model:value="formViewData.success_msg"
:rows="3"
@ -720,8 +725,8 @@ watch(view, (nextView) => {
/>
<!-- Other options -->
<div class="mt-4">
<div class="my-4">
<div class="flex flex-col gap-2 mt-4">
<div class="flex items-center">
<!-- Show "Submit Another Form" button -->
<a-switch
v-model:checked="formViewData.submit_another_form"
@ -733,7 +738,7 @@ watch(view, (nextView) => {
<span class="ml-4">{{ $t('msg.info.submitAnotherForm') }}</span>
</div>
<div class="my-4">
<div class="flex items-center">
<!-- Show a blank form after 5 seconds -->
<a-switch
v-model:checked="formViewData.show_blank_form"
@ -742,10 +747,11 @@ watch(view, (nextView) => {
class="nc-form-checkbox-show-blank-form"
@change="updateView"
/>
<span class="ml-4">{{ $t('msg.info.showBlankForm') }}</span>
</div>
<div class="my-4">
<div class="mb-12 flex items-center">
<a-switch
v-model:checked="emailMe"
v-e="[`a:form-view:email-me`]"
@ -753,6 +759,7 @@ watch(view, (nextView) => {
class="nc-form-checkbox-send-email"
@change="onEmailChange"
/>
<!-- Email me at <email> -->
<span class="ml-4">
{{ $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;
}
}
: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>

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

@ -327,6 +327,22 @@ watch(
},
{ immediate: true },
)
const tbodyEl = ref<HTMLElement>()
watch([() => selected.row, () => selected.col], ([row, col]) => {
if (row !== null && col !== null) {
// get active cell
const td = tbodyEl.value?.querySelectorAll('tr')[row]?.querySelectorAll('td')[col + 1]
if (!td) return
// scroll into the active cell
td.scrollIntoView({
behavior: 'smooth',
block: 'end',
inline: 'end',
})
}
})
</script>
<template>
@ -413,7 +429,7 @@ watch(
</tr>
</thead>
<!-- this prevent select text from field if not in edit mode -->
<tbody @selectstart.prevent>
<tbody ref="tbodyEl" @selectstart.prevent>
<LazySmartsheetRow v-for="(row, rowIndex) of data" ref="rowRefs" :key="rowIndex" :row="row">
<template #default="{ state }">
<tr class="nc-grid-row">

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

@ -14,7 +14,7 @@ const { allowCSVDownload } = useSharedView()
<template>
<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"
>
<LazySmartsheetToolbarViewActions
@ -27,6 +27,8 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarStackedBy v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery" :show-system-fields="false" />
<LazySmartsheetToolbarKanbanStackEditOrAdd v-if="isKanban" />
<LazySmartsheetToolbarFieldsMenu v-if="isGrid || isGallery || isKanban" :show-system-fields="false" class="ml-1" />
@ -40,11 +42,11 @@ const { allowCSVDownload } = useSharedView()
<LazySmartsheetToolbarExport v-if="(!isPublic && !isUIAllowed('dataInsert')) || (isPublic && allowCSVDownload)" />
<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 || isKanban) && !isPublic" class="shrink mr-2 ml-2" />
<LazySmartsheetToolbarSearchData v-if="(isGrid || isGallery || isKanban) && !isPublic" class="shrink mx-2" />
<template v-if="!isOpen && !isPublic">
<div class="border-l-1 pl-3">

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

@ -1,6 +1,6 @@
<script setup lang="ts">
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 { NavigateDir } from '~/lib'
@ -22,14 +22,22 @@ provide(ActiveCellInj, active)
provide(RowInj, row)
provide(CellValueInj, toRef(props, 'modelValue'))
const isForm = inject(IsFormInj, ref(false))
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>
<template>
<div
class="nc-virtual-cell w-full"
@keydown.stop.enter.exact="emit('navigate', NavigateDir.NEXT)"
@keydown.stop.shift.enter.exact="emit('navigate', NavigateDir.PREV)"
@keydown.enter.exact="onNavigate(NavigateDir.NEXT, $event)"
@keydown.shift.enter.exact="onNavigate(NavigateDir.PREV, $event)"
>
<LazyVirtualCellHasMany v-if="isHm" />
<LazyVirtualCellManyToMany v-else-if="isMm" />

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

@ -122,14 +122,15 @@ async function onCreate(view: ViewType) {
:collapsed="sidebarCollapsed"
collapsiple
collapsed-width="0"
width="250"
class="relative shadow-md h-full"
width="0"
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"
>
<LazySmartsheetSidebarToolbar
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"
/>
<div v-if="isOpen" class="flex-1 flex flex-col min-h-0">
<LazySmartsheetSidebarMenuTop @open-modal="openModal" @deleted="loadViews" />

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

@ -16,7 +16,7 @@ const onClick = () => {
<div
v-e="['c:row:add:grid-top']"
: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
:class="{ 'cursor-pointer text-gray-500 group-hover:(text-primary)': !isLocked, 'disabled': isLocked }"

6
packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue

@ -262,10 +262,8 @@ defineExpose({
<style scoped>
.nc-filter-grid {
display: grid;
grid-template-columns: 18px 75px auto auto auto;
@apply gap-[12px]
align-items: center;
grid-template-columns: 18px 83px 160px auto auto;
@apply grid gap-[12px] items-center;
}
:deep(.ant-select-item-option) {

12
packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue

@ -44,9 +44,7 @@ const options = computed<SelectProps['options']>(() =>
})),
)
const filterOption = (input: string, option: any) => {
return option.value.toLowerCase()?.includes(input.toLowerCase())
}
const filterOption = (input: string, option: any) => option.label.toLowerCase()?.includes(input.toLowerCase())
</script>
<template>
@ -58,7 +56,7 @@ const filterOption = (input: string, option: any) => {
:filter-option="filterOption"
dropdown-class-name="nc-dropdown-toolbar-field-list"
>
<a-select-option v-for="option in options" :key="option.value" :value="option.value">
<a-select-option v-for="option in options" :key="option.value" :label="option.label" :value="option.value">
<div class="flex gap-2 items-center items-center h-full">
<component :is="option.icon" class="min-w-5 !mx-0" />
@ -67,3 +65,9 @@ const filterOption = (input: string, option: any) => {
</a-select-option>
</a-select>
</template>
<style lang="scss">
.ant-select-selection-search-input {
box-shadow: none !important;
}
</style>

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

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

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

@ -1,9 +1,12 @@
<script lang="ts" setup>
import { ViewTypes } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import tinycolor from 'tinycolor2'
import {
computed,
extractSdkResponseErrorMsg,
message,
projectThemeColors,
ref,
useCopy,
useDashboard,
@ -14,6 +17,7 @@ import {
useUIPermission,
watch,
} from '#imports'
import type { SharedView } from '~/lib'
const { t } = useI18n()
@ -33,31 +37,52 @@ let showShareModel = $ref(false)
const passwordProtected = ref(false)
const shared = ref()
const shared = ref<SharedView>({ id: '', meta: {}, password: undefined })
const allowCSVDownload = computed({
get() {
return !!(shared.value?.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta)
?.allowCSVDownload
},
set(allow) {
shared.value.meta = { allowCSVDownload: allow }
get: () => !!shared.value.meta.allowCSVDownload,
set: (allow) => {
shared.value.meta = { ...shared.value.meta, allowCSVDownload: allow }
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 () => {
shared.value = await $api.dbViewShare.create(view.value?.id as string)
shared.value.meta =
shared.value.meta && typeof shared.value.meta === 'string' ? JSON.parse(shared.value.meta) : shared.value.meta
passwordProtected.value = shared.value.password !== null && shared.value.password !== ''
if (!view.value?.id) return
const response = (await $api.dbViewShare.create(view.value.id)) as SharedView
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
}
const sharedViewUrl = computed(() => {
if (!shared.value) return
let viewType
let viewType
switch (shared.value.type) {
case ViewTypes.FORM:
viewType = 'form'
@ -73,21 +98,34 @@ const sharedViewUrl = computed(() => {
})
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 {
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, {
meta,
})
// Successfully updated
message.success(t('msg.success.updated'))
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
if (allowCSVDownload?.value) {
$e('a:view:share:enable-csv-download')
} else {
$e('a:view:share:disable-csv-download')
}
return true
}
const saveShareLinkPassword = async () => {
@ -104,10 +142,27 @@ const saveShareLinkPassword = async () => {
$e('a:view:share:enable-pwd')
}
const copyLink = () => {
copy(sharedViewUrl?.value as string)
// Copied to clipboard
message.success(t('msg.info.copiedToClipboard'))
function onChangeTheme(color: string) {
const tcolor = tinycolor(color)
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) => {
@ -125,8 +180,9 @@ watch(passwordProtected, (value) => {
v-e="['c:view:share']"
outlined
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 />
<!-- Share View -->
<span class="!text-sm font-weight-normal"> {{ $t('activity.shareView') }}</span>
@ -139,10 +195,13 @@ watch(passwordProtected, (value) => {
size="small"
:title="$t('msg.info.privateLink')"
:footer="null"
width="min(100vw,640px)"
width="min(100vw,720px)"
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>
<a v-e="['c:view:share:open-url']" :href="sharedViewUrl" target="_blank">
@ -152,37 +211,76 @@ watch(passwordProtected, (value) => {
<MdiContentCopy v-e="['c:view:share:copy-url']" class="text-gray-500 text-sm cursor-pointer" @click="copyLink" />
</div>
<a-collapse ghost>
<a-collapse-panel key="1" :header="$t('general.showOptions')">
<div class="mb-2">
<a-checkbox v-model:checked="passwordProtected" class="!text-xs">{{ $t('msg.info.beforeEnablePwd') }} </a-checkbox>
<div class="px-1 mt-2 flex flex-col gap-3">
<!-- todo: i18n -->
<div class="text-gray-500 border-b-1">Options</div>
<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
v-model:value="shared.password"
data-cy="nc-modal-share-view__password"
size="small"
class="!text-xs max-w-[250px]"
type="password"
: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') }}
</a-button>
</div>
</div>
<div>
<!-- Allow Download -->
<a-checkbox
v-if="shared && (shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN)"
v-if="shared && shared.type === ViewTypes.GRID || shared.type === ViewTypes.KANBAN"
v-model:checked="allowCSVDownload"
data-cy="nc-modal-share-view__with-csv-download"
class="!text-xs"
>
{{ $t('labels.downloadAllowed') }}
</a-checkbox>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</a-modal>
</div>
</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 isLocked = inject(IsLockedInj)
const isLocked = inject(IsLockedInj, ref(false))
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 { ColumnType, FormType, LinkToAnotherRecordType, TableType, ViewType } from 'nocodb-sdk'
import { ErrorMessages, RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import {
SharedViewPasswordInj,
computed,
createEventHook,
extractSdkResponseErrorMsg,
message,
provide,
@ -16,6 +18,7 @@ import {
useProvideSmartsheetRowStore,
watch,
} from '#imports'
import type { SharedViewMeta } from '~/lib'
const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((sharedViewId: string) => {
const progress = ref(false)
@ -23,6 +26,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const submitted = ref(false)
const passwordDlg = ref(false)
const password = ref<string | null>(null)
const passwordError = ref<string | null>(null)
const secondsRemain = ref(0)
provide(SharedViewPasswordInj, password)
@ -31,6 +35,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const sharedFormView = ref<FormType>()
const meta = ref<TableType>()
const columns = ref<(ColumnType & { required?: boolean; show?: boolean; label?: string })[]>()
const sharedViewMeta = ref<SharedViewMeta>({})
const formResetHook = createEventHook<void>()
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),
)
const loadSharedView = async () => {
passwordError.value = null
try {
const viewMeta = await api.public.sharedViewMetaGet(sharedViewId, {
headers: {
@ -65,6 +73,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
meta.value = viewMeta.model
columns.value = viewMeta.model?.columns
const _sharedViewMeta = (viewMeta as any).meta
sharedViewMeta.value = isString(_sharedViewMeta) ? JSON.parse(_sharedViewMeta) : _sharedViewMeta
await setMeta(viewMeta.model)
const relatedMetas = { ...viewMeta.relatedMetas }
@ -75,6 +86,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
notFound.value = true
} else if ((await extractSdkResponseErrorMsg(e)) === ErrorMessages.INVALID_SHARED_VIEW_PASSWORD) {
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) {
submitted.value = false
formResetHook.trigger()
clearInterval(intvl)
}
}, 1000)
@ -187,6 +202,10 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}
})
watch(password, (next, prev) => {
if (next !== prev && passwordError.value) passwordError.value = null
})
return {
sharedView,
sharedFormView,
@ -201,10 +220,13 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
formState,
notFound,
password,
passwordError,
submitted,
secondsRemain,
passwordDlg,
isLoading,
sharedViewMeta,
onReset: formResetHook.on,
}
}, '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 */
setTheme(config ?? currentTheme.value)
/** set theme (persists in localstorage) */
/** set theme */
function setTheme(theme?: Partial<ThemeConfig>) {
const themePrimary = theme?.primaryColor ? tinycolor(theme.primaryColor) : tinycolor(themeV2Colors['royal-blue'].DEFAULT)
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'])
currentTheme.value = {
primaryColor: themePrimary.toHex8String().toUpperCase(),
accentColor: themeAccent.toHex8String().toUpperCase(),
primaryColor: themePrimary.toHex8String().toUpperCase().slice(0, -2),
accentColor: themeAccent.toHex8String().toUpperCase().slice(0, -2),
}
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 SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('shared-view-password-injection')
export const CellUrlDisableOverlayInj: InjectionKey<Ref<boolean>> = Symbol('cell-url-disable-url')
export const DropZoneRef: InjectionKey<Ref<Element | undefined>> = Symbol('drop-zone-ref')

1
packages/nc-gui/lang/ar.json

@ -292,6 +292,7 @@
"documentation": "الوثائق",
"subscribeNewsletter": "اشترك في نشرتنا الإخبارية الأسبوعية",
"signUpWithGoogle": "التسجيل بواسطة Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "بالتسجيل، أنت توافق على شروط الخدمة",
"welcomeToNc": "مرحبا بكم في NocoDB!"
},

1
packages/nc-gui/lang/bn_IN.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/da.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/de.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

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

@ -293,6 +293,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/es.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/fa.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/fi.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/fr.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/he.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/hi.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/hr.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/id.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/it.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/ja.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/ko.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/lv.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/nl.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/no.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/pl.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/pt.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/pt_BR.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/ru.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/sl.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/sv.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/th.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/tr.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/uk.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/vi.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/zh-Hans.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

1
packages/nc-gui/lang/zh-Hant.json

@ -292,6 +292,7 @@
"documentation": "Documentation",
"subscribeNewsletter": "Subscribe to our weekly newsletter",
"signUpWithGoogle": "Sign up with Google",
"signInWithGoogle": "Sign in with Google",
"agreeToTos": "By signing up, you agree to the Terms of Service",
"welcomeToNc": "Welcome to NocoDB!"
},

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 { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { ProjectRole, Role, TabType } from './enums'
@ -70,3 +70,18 @@ export interface TabItem {
viewTitle?: 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,
useSidebar,
useTabs,
useTheme,
useUIPermission,
} from '#imports'
import { TabType } from '~/lib'
@ -29,6 +30,8 @@ definePageMeta({
hideHeader: true,
})
const { theme } = useTheme()
const { t } = useI18n()
const route = useRoute()
@ -336,6 +339,7 @@ onBeforeUnmount(reset)
<template #expandIcon></template>
<LazyGeneralColorPicker
:model-value="theme.primaryColor"
:colors="projectThemeColors"
:row-size="9"
:advanced="false"

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

@ -4,14 +4,18 @@ import {
IsPublicInj,
MetaInj,
ReloadViewDataHookInj,
createError,
createEventHook,
definePageMeta,
navigateTo,
provide,
reactive,
ref,
useProvideSharedFormStore,
useProvideSmartsheetStore,
useRoute,
useSidebar,
watch,
} from '#imports'
definePageMeta({
@ -22,7 +26,9 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
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()
@ -33,11 +39,85 @@ if (!notFound.value) {
provide(IsFormInj, ref(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>
<template>
<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>
</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">
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { useSharedFormStoreOrThrow } from '#imports'
const {
sharedFormView,
submitForm,
v$,
formState,
notFound,
formColumns,
submitted,
secondsRemain,
passwordDlg,
password,
loadSharedView,
} = 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>
}
import { navigateTo, useDark, useRoute, useRouter, useSharedFormStoreOrThrow, useTheme, watch } from '#imports'
const { sharedViewMeta } = useSharedFormStoreOrThrow()
const isDark = useDark()
const { setTheme } = useTheme()
const route = useRoute()
const router = useRouter()
watch(
() => sharedViewMeta.value.withTheme,
(hasTheme) => {
if (hasTheme && sharedViewMeta.value.theme) setTheme(sharedViewMeta.value.theme)
},
{ immediate: true },
)
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>
<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
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">
<img width="90" height="90" alt="NocoDB" class="mx-auto" src="~/assets/img/icons/512x512.png" />
<h1 class="prose-2xl font-bold self-center my-4">{{ sharedFormView?.heading }}</h1>
<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>
<Transition name="slide-left" duration="250" mode="out-in">
<MaterialSymbolsDarkModeOutline v-if="isDark" />
<MaterialSymbolsLightModeOutline v-else />
</Transition>
</div>
</div>
</template>
<style lang="scss">
html,
body,
h1,
h2,
h3,
h4,
h5,
h6,
p {
@apply dark:text-white color-transition;
}
.nc-form-view {
.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>

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>
<template>
<div class="w-full h-[300px] flex justify-center items-center text-4xl">
<div class="text-gray-400 flex gap-2 items-center">
<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-4 items-center">
<MdiWarning />
Page Not Found
</div>

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

@ -9,9 +9,9 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
<template>
<NuxtLayout>
<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>
<LazyGeneralSponsors />
</div>
@ -21,14 +21,14 @@ useSidebar('nc-left-sidebar', { hasSidebar: false })
<NuxtPage />
</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'">
<TransitionGroup name="page" mode="out-in">
<div key="social-card">
<LazyGeneralSocialCard />
</div>
<div key="sponsors" class="block mt-0 lg:(!mt-6) xl:hidden">
<div key="sponsors" class="inline-block xl:hidden">
<LazyGeneralSponsors />
</div>
</TransitionGroup>

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

@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk'
import tinycolor from 'tinycolor2'
import { breakpointsTailwind } from '@vueuse/core'
import {
Empty,
Modal,
@ -14,6 +15,8 @@ import {
ref,
themeV2Colors,
useApi,
useBreakpoints,
useGlobal,
useNuxtApp,
useUIPermission,
} from '#imports'
@ -28,10 +31,14 @@ const { api, isLoading } = useApi()
const { isUIAllowed } = useUIPermission()
const { md } = useBreakpoints(breakpointsTailwind)
const filterQuery = ref('')
const projects = ref<ProjectType[]>()
const { appInfo } = useGlobal()
const loadProjects = async () => {
const response = await api.project.list({})
projects.value = response.list
@ -128,12 +135,19 @@ onBeforeMount(loadProjects)
<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)">
<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>
</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">
<span
class="transition-all duration-200 h-full flex items-center group hover:ring active:(ring ring-accent) rounded-full mt-1"
<div
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' : ''"
>
<MdiRefresh
@ -142,21 +156,13 @@ onBeforeMount(loadProjects)
:class="isLoading ? '!text-primary' : ''"
@click="loadProjects"
/>
</span>
</div>
</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" />
<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">
{{ $t('title.newProj') }}
<MdiMenuDown class="menu-icon" />
@ -177,7 +183,7 @@ onBeforeMount(loadProjects)
</div>
</a-menu-item>
<a-menu-item>
<a-menu-item v-if="appInfo.connectToExternalDB">
<div
v-e="['c:project:create:extdb']"
class="nc-project-menu-item group nc-create-external-db-project"
@ -198,7 +204,13 @@ onBeforeMount(loadProjects)
<a-skeleton />
</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>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</template>
@ -225,6 +237,7 @@ onBeforeMount(loadProjects)
<template #expandIcon></template>
<LazyGeneralColorPicker
:model-value="getProjectPrimary(record)"
:colors="projectThemeColors"
:row-size="9"
:advanced="false"

14
packages/nc-gui/pages/signin.vue

@ -7,7 +7,7 @@ definePageMeta({
title: 'title.headLogin',
})
const { signIn: _signIn } = useGlobal()
const { signIn: _signIn, appInfo } = useGlobal()
const { api, isLoading, error } = useApi({ useGlobalInstance: true })
@ -119,6 +119,18 @@ function resetError() {
</span>
</button>
<a
v-if="appInfo.googleAuthEnabled"
:href="`${appInfo.ncSiteUrl}/auth/google`"
class="scaling-btn bg-opacity-100 after:(!bg-white) !text-primary !no-underline"
>
<span class="flex items-center gap-2">
<LogosGoogleGmail />
{{ $t('labels.signInWithGoogle') }}
</span>
</a>
<div class="text-end prose-sm">
{{ $t('msg.info.signUp.dontHaveAccount') }}
<nuxt-link to="/signup">{{ $t('general.signUp') }}</nuxt-link>

4
packages/nc-gui/pages/signup/[[token]].vue

@ -132,13 +132,13 @@ function resetError() {
<a
v-if="appInfo.googleAuthEnabled"
:href="`${api.instance.defaults.baseURL}/auth/google`"
:href="`${appInfo.ncSiteUrl}/auth/google`"
class="scaling-btn bg-opacity-100 after:(!bg-white) !text-primary !no-underline"
>
<span class="flex items-center gap-2">
<LogosGoogleGmail />
Sign up with Google
{{ $t('labels.signUpWithGoogle') }}
</span>
</a>

9
packages/nocodb/src/lib/meta/api/projectApis.ts

@ -55,7 +55,11 @@ export async function projectUpdate(
'color',
]);
if (data?.title && project.title !== data.title && await Project.getByTitle(data.title)) {
if (
data?.title &&
project.title !== data.title &&
(await Project.getByTitle(data.title))
) {
NcError.badRequest('Project title already in use');
}
@ -114,6 +118,9 @@ async function projectCreate(req: Request<any, any>, res) {
},
];
} else {
if (process.env.NC_CONNECT_TO_EXTERNAL_DB_DISABLED) {
NcError.badRequest('Connecting to external db is disabled');
}
projectBody.is_meta = false;
}

25
packages/nocodb/src/lib/meta/helpers/webhookHelpers.ts

@ -116,20 +116,33 @@ export async function handleHttpWebHook(apiMeta, user, data) {
export function axiosRequestMake(_apiMeta, _user, data) {
const apiMeta = { ..._apiMeta };
// if it's a string try to parse and apply handlebar
// or if object then convert into JSON string and parse it
if (apiMeta.body) {
try {
apiMeta.body = JSON.parse(apiMeta.body, (_key, value) => {
return typeof value === 'string' ? parseBody(value, data) : value;
});
apiMeta.body = JSON.parse(
typeof apiMeta.body === 'string'
? apiMeta.body
: JSON.stringify(apiMeta.body),
(_key, value) => {
return typeof value === 'string' ? parseBody(value, data) : value;
}
);
} catch (e) {
// if string parsing failed then directly apply the handlebar
apiMeta.body = parseBody(apiMeta.body, data);
}
}
if (apiMeta.auth) {
try {
apiMeta.auth = JSON.parse(apiMeta.auth, (_key, value) => {
return typeof value === 'string' ? parseBody(value, data) : value;
});
apiMeta.auth = JSON.parse(
typeof apiMeta.auth === 'string'
? apiMeta.auth
: JSON.stringify(apiMeta.auth),
(_key, value) => {
return typeof value === 'string' ? parseBody(value, data) : value;
}
);
} catch (e) {
apiMeta.auth = parseBody(apiMeta.auth, data);
}

5
scripts/cypress/integration/common/1b_table_column_operations.js

@ -8,10 +8,7 @@ export const genTest = (apiType, dbType) => {
if (!isTestSuiteActive(apiType, dbType)) return;
function addNewRow(index, cellValue) {
cy.get(".nc-add-new-row-btn:visible").should("exist");
cy.get(".nc-add-new-row-btn").click();
cy.wait(2000);
mainPage.addNewRowExpand("tablex");
// cy.get("#data-table-form-Title > input").first().type(cellValue);
cy.get(".nc-expand-col-Title")

2
scripts/cypress/integration/common/3a_filter_sort_fields_operations.js

@ -52,7 +52,7 @@ export const genTest = (apiType, dbType) => {
);
// add a row to end of Country table
cy.get(".nc-add-new-row-btn").click();
mainPage.addNewRowExpand("Country");
cy.get(".nc-expand-col-Country")
.find(".nc-cell > input")
.first()

3
scripts/cypress/integration/common/3e_duration_column.js

@ -127,8 +127,7 @@ export const genTest = (apiType, dbType) => {
isNewRow = false
) => {
if (isNewRow) {
cy.get(".nc-add-new-row-btn:visible").should("exist");
cy.get(".nc-add-new-row-btn").click();
mainPage.addNewRowExpand("DurationTable");
} else {
// mainPage.getRow(index).find(".nc-row-expand-icon").click({ force: true });
cy.get(".nc-row-expand")

3
scripts/cypress/integration/common/3f_link_to_another_record.js

@ -183,8 +183,7 @@ export const genTest = (apiType, dbType) => {
cy.openTableTab("Sheet2", 0);
// Click on `Add new row` button
cy.get(".nc-add-new-row-btn:visible").should("exist");
cy.get(".nc-add-new-row-btn").click();
mainPage.addNewRowExpand("Sheet2");
// Title
cy.get(".nc-expand-col-Title")

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

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

2
scripts/cypress/integration/common/4f_grid_view_share.js

@ -401,7 +401,7 @@ export const genTest = (apiType, dbType) => {
it(`Generate default Shared GRID view URL`, () => {
// add row
cy.get(".nc-add-new-row-btn").click();
mainPage.addNewRowExpand("Country");
cy.get(".nc-expand-col-Country")
.find(".nc-cell > input")
.should("exist")

2
scripts/cypress/integration/common/4f_pg_grid_view_share.js

@ -428,7 +428,7 @@ export const genTest = (apiType, dbType) => {
it(`Generate default Shared GRID view URL`, () => {
// add row
cy.get(".nc-add-new-row-btn").click();
mainPage.addNewRowExpand("Country");
cy.get(".nc-expand-col-Country")
.find(".nc-cell > input")
.should("exist")

3
scripts/cypress/integration/common/8a_webhook.js

@ -148,8 +148,7 @@ function clearServerData() {
}
function addNewRow(index, cellValue) {
cy.get(".nc-add-new-row-btn:visible").should("exist");
cy.get(".nc-add-new-row-btn").click();
mainPage.addNewRowExpand("Temp");
cy.get(".nc-expand-col-Title")
.should("exist")

6
scripts/cypress/support/commands.js

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

15
scripts/cypress/support/page_objects/mainPage.js

@ -161,6 +161,21 @@ export class _mainPage {
});
};
addNewRowExpand(table) {
cy.get(".nc-add-new-row-btn:visible").should("exist");
cy.get(".nc-add-new-row-btn").click();
// cy.wait(2000);
// cy.get(`.nc-tooltip-content:contains("Add new row")`).should("not.exist");
// 'Add new row' tooltip persists for a while; force click on header to make it disappear
cy.get(
`.nc-drawer-expanded-form .nc-expanded-form-header :contains("${table}")`
)
.should("exist")
.click({ force: true });
}
// addExistingUserToProject = (emailId, role) => {
// cy.get('.v-list-item:contains("Team & Auth")').click();
// cy.get(`tr:contains(${emailId})`)

Loading…
Cancel
Save