Browse Source

Merge pull request #3188 from nocodb/feat/shared-form-view-2

shared form view
pull/3221/head
navi 2 years ago committed by GitHub
parent
commit
940a5ff235
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/nc-gui-v2/components/cell/attachment/Carousel.vue
  2. 12
      packages/nc-gui-v2/components/cell/attachment/Modal.vue
  3. 44
      packages/nc-gui-v2/components/cell/attachment/index.vue
  4. 54
      packages/nc-gui-v2/components/cell/attachment/utils.ts
  5. 157
      packages/nc-gui-v2/components/shared-view/Form.vue
  6. 5
      packages/nc-gui-v2/components/virtual-cell/components/ListChildItems.vue
  7. 5
      packages/nc-gui-v2/components/virtual-cell/components/ListItems.vue
  8. 1
      packages/nc-gui-v2/composables/index.ts
  9. 28
      packages/nc-gui-v2/composables/useLTARStore.ts
  10. 197
      packages/nc-gui-v2/composables/useSharedFormViewStore.ts
  11. 9
      packages/nc-gui-v2/composables/useSmartsheetStore.ts
  12. 1
      packages/nc-gui-v2/context/index.ts
  13. 9
      packages/nc-gui-v2/layouts/base.vue
  14. 3
      packages/nc-gui-v2/middleware/auth.global.ts
  15. 100
      packages/nc-gui-v2/package-lock.json
  16. 2
      packages/nc-gui-v2/package.json
  17. 36
      packages/nc-gui-v2/pages/[projectType]/form/[viewId].vue
  18. 38
      packages/nocodb-sdk/src/lib/Api.ts
  19. 5
      packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts
  20. 106
      scripts/sdk/swagger.json

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

@ -92,7 +92,7 @@ onClickOutside(carouselRef, () => {
<div v-for="item of imageItems" :key="item.url">
<div
:style="{ backgroundImage: `url('${item.url}')` }"
:style="{ backgroundImage: `url('${item.url || item.data}')` }"
class="min-w-70vw min-h-70vh w-full h-full bg-contain bg-center bg-no-repeat"
/>
</div>

12
packages/nc-gui-v2/components/cell/attachment/Modal.vue

@ -10,7 +10,6 @@ const {
open,
isLoading,
isPublicGrid,
isForm,
isReadonly,
visibleItems,
modalVisible,
@ -34,6 +33,8 @@ const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, is
const { isOverDropZone } = useDropZone(dropZoneRef, onDrop)
const { isSharedForm } = useSmartsheetStoreOrThrow()
onKeyDown('Escape', () => {
modalVisible.value = false
isOverDropZone.value = false
@ -61,7 +62,7 @@ function onClick(item: Record<string, any>) {
<template #title>
<div class="flex gap-4">
<div
v-if="!isReadonly && (isForm || isUIAllowed('tableAttachment')) && !isPublicGrid && !isLocked"
v-if="isSharedForm || (!isReadonly && isUIAllowed('tableAttachment') && !isPublicGrid && !isLocked)"
class="nc-attach-file group"
@click="open"
>
@ -78,7 +79,7 @@ function onClick(item: Record<string, any>) {
</template>
<div ref="dropZoneRef">
<template v-if="!isReadonly && !dragging">
<template v-if="isSharedForm || (!isReadonly && !dragging)">
<general-overlay
v-model="isOverDropZone"
inline
@ -94,9 +95,8 @@ function onClick(item: Record<string, any>) {
<a-card class="nc-attachment-item group">
<a-tooltip v-if="!isReadonly">
<template #title> Remove File </template>
<MdiCloseCircle
v-if="isUIAllowed('tableAttachment') && !isPublicGrid && !isLocked"
v-if="isSharedForm || (isUIAllowed('tableAttachment') && !isPublicGrid && !isLocked)"
class="nc-attachment-remove"
@click.stop="removeFile(i)"
/>
@ -116,7 +116,7 @@ function onClick(item: Record<string, any>) {
>
<div
v-if="isImage(item.title, item.mimetype)"
:style="{ backgroundImage: `url('${item.url}')` }"
:style="{ backgroundImage: `url('${item.url || item.data}')` }"
class="w-full h-full bg-contain bg-center bg-no-repeat"
@click.stop="onClick(item)"
/>

44
packages/nc-gui-v2/components/cell/attachment/index.vue

@ -4,7 +4,16 @@ import { useProvideAttachmentCell } from './utils'
import { useSortable } from './sort'
import Modal from './Modal.vue'
import Carousel from './Carousel.vue'
import { computed, isImage, openLink, ref, useDropZone, useSmartsheetStoreOrThrow, watch } from '#imports'
import {
computed,
isImage,
openLink,
ref,
useDropZone,
useSmartsheetRowStoreOrThrow,
useSmartsheetStoreOrThrow,
watch,
} from '#imports'
interface Props {
modelValue: string | Record<string, any>[] | null
@ -23,13 +32,26 @@ const sortableRef = ref<HTMLDivElement>()
const { cellRefs } = useSmartsheetStoreOrThrow()!
const { column, modalVisible, attachments, visibleItems, onDrop, isLoading, open, FileIcon, selectedImage, isReadonly } =
useProvideAttachmentCell(updateModelValue)
const {
column,
modalVisible,
attachments,
visibleItems,
onDrop,
isLoading,
open,
FileIcon,
selectedImage,
isReadonly,
storedFiles,
} = useProvideAttachmentCell(updateModelValue)
const currentCellRef = computed(() => cellRefs.value.find((cell) => cell.dataset.key === `${rowIndex}${column.value.id}`))
const { dragging } = useSortable(sortableRef, visibleItems, updateModelValue, isReadonly)
const { state: rowState } = useSmartsheetRowStoreOrThrow()
const { isOverDropZone } = useDropZone(currentCellRef, onDrop)
/** on new value, reparse our stored attachments */
@ -37,7 +59,11 @@ watch(
() => modelValue,
(nextModel) => {
if (nextModel) {
try {
attachments.value = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean)
} catch {
attachments.value = []
}
}
},
{ immediate: true },
@ -53,13 +79,23 @@ onKeyDown('Escape', () => {
modalVisible.value = false
isOverDropZone.value = false
})
/** sync storedFiles state with row state */
watch(
() => storedFiles.value.length || 0,
() => {
rowState.value[column.value.title!] = storedFiles.value
},
)
const { isSharedForm } = useSmartsheetStoreOrThrow()
</script>
<template>
<div class="nc-attachment-cell relative flex-1 color-transition flex items-center justify-between gap-1">
<Carousel />
<template v-if="!isReadonly && !dragging && !!currentCellRef">
<template v-if="isSharedForm || (!isReadonly && !dragging && !!currentCellRef)">
<general-overlay
v-model="isOverDropZone"
inline

54
packages/nc-gui-v2/components/cell/attachment/utils.ts

@ -1,7 +1,7 @@
import { message } from 'ant-design-vue'
import FileSaver from 'file-saver'
import { computed, inject, ref, useApi, useFileDialog, useInjectionState, useProject, watch } from '#imports'
import { ColumnInj, EditModeInj, MetaInj, ReadonlyInj } from '~/context'
import { ColumnInj, EditModeInj, IsPublicInj, MetaInj, ReadonlyInj } from '~/context'
import { isImage } from '~/utils'
import { NOCO } from '~/lib'
import MdiPdfBox from '~icons/mdi/pdf-box'
@ -10,11 +10,17 @@ import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box'
import MdiFileExcelOutline from '~icons/mdi/file-excel-outline'
import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file'
interface AttachmentProps {
data?: any
file: File
title: string
}
export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
(updateModelValue: (data: string | Record<string, any>[]) => void) => {
const isReadonly = inject(ReadonlyInj, false)
const isPublicForm = inject('isPublicForm', false)
const isPublic = inject(IsPublicInj, ref(false))
const isForm = inject('isForm', false)
@ -27,7 +33,11 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
const editEnabled = inject(EditModeInj, ref(false))
const storedFiles = ref<{ title: string; file: File }[]>([])
/** keep user selected files data (in base encoded string format) and meta details */
const storedFilesData = ref<{ title: string; file: File }[]>([])
/** keep user selected File object */
const storedFiles = ref<File[]>([])
const attachments = ref<File[]>([])
@ -43,10 +53,11 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
/** remove a file from our stored attachments (either locally stored or saved ones) */
function removeFile(i: number) {
if (isPublicForm) {
if (isPublic.value) {
storedFilesData.value.splice(i, 1)
storedFiles.value.splice(i, 1)
updateModelValue(storedFiles.value.map((storedFile) => storedFile.file))
updateModelValue(storedFilesData.value.map((storedFile) => storedFile.file))
} else {
attachments.value.splice(i, 1)
updateModelValue(attachments.value)
@ -57,19 +68,33 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
async function onFileSelect(selectedFiles: FileList | File[]) {
if (!selectedFiles.length || isPublicGrid) return
if (isPublicForm) {
storedFiles.value.push(
...Array.from(selectedFiles).map((file) => {
const res = { file, title: file.name }
if (isPublic.value) {
storedFiles.value.push(...selectedFiles)
storedFilesData.value.push(
...(await Promise.all<AttachmentProps>(
Array.from(selectedFiles).map(
(file) =>
new Promise<AttachmentProps>((resolve) => {
const res: AttachmentProps = { file, title: file.name }
if (isImage(file.name, (file as any).mimetype)) {
const reader = new FileReader()
reader.onload = (e: any) => {
res.data = e.target?.result
resolve(res)
}
reader.onerror = () => {
resolve(res)
}
reader.readAsDataURL(file)
} else {
resolve(res)
}
return res
}),
),
)),
)
return updateModelValue(storedFiles.value.map((storedFile) => storedFile.file))
return updateModelValue(storedFilesData.value.map((storedFile) => storedFile.file))
}
const newAttachments = []
@ -124,15 +149,15 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
}
/** our currently visible items, either the locally stored or the ones from db, depending on isPublicForm status */
const visibleItems = computed<any[]>(() => (isPublicForm ? storedFiles.value : attachments.value) || ([] as any[]))
const visibleItems = computed<any[]>(() => (isPublic.value ? storedFilesData.value : attachments.value) || ([] as any[]))
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles))
return {
attachments,
storedFiles,
storedFilesData,
visibleItems,
isPublicForm,
isPublic,
isForm,
isPublicGrid,
isReadonly,
@ -149,6 +174,7 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState(
downloadFile,
updateModelValue,
selectedImage,
storedFiles,
}
},
'useAttachmentCell',

157
packages/nc-gui-v2/components/shared-view/Form.vue

@ -1,12 +1,20 @@
<script setup lang="ts">
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { FieldsInj, MetaInj } from '#imports'
import { useSharedFormStoreOrThrow } from '#imports'
const fields = inject(FieldsInj, ref([]))
const meta = inject(MetaInj)
const { sharedView } = useSharedView()
const formState = ref(fields.value.reduce((a, v) => ({ ...a, [v.title]: undefined }), {}))
const {
sharedFormView,
submitForm,
v$,
formState,
notFound,
formColumns,
submitted,
secondsRemain,
passwordDlg,
password,
loadSharedView,
} = useSharedFormStoreOrThrow()
function isRequired(_columnObj: Record<string, any>, required = false) {
let columnObj = _columnObj
@ -15,7 +23,7 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
columnObj.colOptions &&
columnObj.colOptions.type === RelationTypes.BELONGS_TO
) {
columnObj = fields.value.find((c: Record<string, any>) => c.id === columnObj.colOptions.fk_child_column_id) as Record<
columnObj = formColumns.value?.find((c: Record<string, any>) => c.id === columnObj.colOptions.fk_child_column_id) as Record<
string,
any
>
@ -23,25 +31,58 @@ function isRequired(_columnObj: Record<string, any>, required = false) {
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
useSmartsheetStoreOrThrow()
useProvideSmartsheetRowStore(meta, formState)
const formRef = ref()
</script>
<template>
<div class="flex flex-col my-4 space-y-2 mx-32 items-center">
<div class="flex w-2/3 flex-col mt-10">
<div class="flex flex-col items-start px-14 py-8 bg-gray-50 rounded-md w-full">
<a-typography-title class="border-b-1 border-gray-100 w-full pb-3 nc-share-form-title" :level="1">
{{ sharedView.view.heading }}
</a-typography-title>
<a-typography class="pl-1 text-sm nc-share-form-desc">{{ sharedView.view.subheading }}</a-typography>
<div class="bg-primary/100 !h-[100vh] overflow-auto w-100 flex flex-col">
<div>
<img src="~/assets/img/icons/512x512-trans.png" width="30" class="mx-4 mt-2" />
</div>
<div class="m-4 mt-2 bg-white rounded p-2 flex-1">
<a-alert v-if="notFound" type="warning" class="mx-auto mt-10 max-w-[300px]" message="Not found"> </a-alert>
<template v-else-if="submitted">
<div class="flex justify-center">
<div v-if="sharedFormView" style="min-width: 350px" class="mt-3">
<a-alert type="success" outlined :message="sharedFormView.success_msg || 'Successfully submitted form data'">
</a-alert>
<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>
<div v-else-if="sharedFormView" class="">
<a-row class="justify-center">
<a-col :md="20">
<div>
<div class="h-full ma-0 rounded-b-0">
<div
class="nc-form-wrapper pb-10 rounded shadow-xl"
style="background: linear-gradient(180deg, #dbdbdb 0, #dbdbdb 200px, white 200px)"
>
<div class="mt-10 flex items-center justify-center flex-col">
<div class="nc-form-banner backgroundColor darken-1 flex-column justify-center d-flex">
<div class="flex items-center justify-center grow h-[100px]">
<img src="~/assets/img/icon.png" width="50" class="mx-4" />
<span class="text-4xl font-weight-bold">NocoDB</span>
</div>
</div>
</div>
<div class="mx-auto nc-form bg-white shadow-lg pa-2 mb-10 max-w-[600px] mx-auto rounded">
<h2 class="mt-4 text-4xl font-weight-bold text-left mx-4 mb-3 px-1">
{{ sharedFormView.heading }}
</h2>
<a-form ref="formRef" :model="formState" class="mt-8 pb-12 mb-8 px-3 bg-gray-50 rounded-md">
<div v-for="(field, index) in fields" :key="index" class="flex flex-col mt-4 px-10 pt-6 space-y-2">
<div class="text-lg text-left mx-4 py-2 px-1 text-gray-500">
{{ sharedFormView.subheading }}
</div>
<div class="h-100">
<div v-for="(field, index) in formColumns" :key="index" class="flex flex-col mt-4 px-4 space-y-2">
<div class="flex">
<SmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
@ -56,27 +97,63 @@ const formRef = ref()
:hide-menu="true"
/>
</div>
<a-form-item
v-if="isVirtualCol(field)"
class="ma-0 gap-0 pa-0"
:class="`nc-form-field-${field.title}`"
:name="field.title"
:rules="[{ required: field.required, message: `${field.title} is required` }]"
>
<SmartsheetVirtualCell v-model="formState[field.title]" class="nc-input" :column="field" />
</a-form-item>
<a-form-item
v-else
class="ma-0 gap-0 pa-0"
:class="`nc-form-field-${field.title}`"
:name="field.title"
:rules="[{ required: field.required, message: `${field.title} is required` }]"
<div v-if="isVirtualCol(field)" class="mt-0">
<SmartsheetVirtualCell class="mt-0 nc-input" :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">
<SmartsheetCell
v-model="formState[field.title]"
class="nc-input"
: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">
<a-button type="primary" size="large" @click="submitForm(formState, additionalState)"> Submit</a-button>
</div>
</div>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
</div>
<a-modal
v-model:visible="passwordDlg"
:closable="false"
width="28rem"
centered
:footer="null"
:mask-closable="false"
@close="passwordDlg = false"
>
<SmartsheetCell v-model="formState[field.title]" class="nc-input" :column="field" :edit-enabled="true" />
<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: 'Password is required' }]">
<a-input-password v-model:value="password" placeholder="Enter password" />
</a-form-item>
</div>
<a-button type="primary" html-type="submit">Unlock</a-button>
</a-form>
</div>
</a-modal>
</div>
</template>
@ -84,4 +161,8 @@ const formRef = ref()
.nc-input {
@apply w-full !bg-white rounded px-2 py-2 min-h-[40px] mt-2 mb-2 flex align-center border-solid border-1 border-primary;
}
.nc-form-wrapper {
@apply my-0 mx-auto max-w-[800px];
}
</style>

5
packages/nc-gui-v2/components/virtual-cell/components/ListChildItems.vue

@ -11,6 +11,7 @@ import {
useVModel,
watch,
} from '#imports'
import { IsPublicInj } from '~/context'
const props = defineProps<{ modelValue?: boolean }>()
@ -22,6 +23,8 @@ const vModel = useVModel(props, 'modelValue', emit)
const isForm = inject(IsFormInj, ref(false))
const isPublic = inject(IsPublicInj, ref(false))
const column = inject(ColumnInj)
const readonly = inject(ReadonlyInj, false)
@ -117,7 +120,7 @@ const expandedFormRow = ref()
@click.stop="unlinkRow(row)"
/>
<MdiDeleteOutline
v-if="!readonly"
v-if="!readonly && !isPublic"
class="text-xs text-grey hover:(!text-red-500) cursor-pointer"
@click.stop="deleteRelatedRow(row, unlinkIfNewRow)"
/>

5
packages/nc-gui-v2/components/virtual-cell/components/ListItems.vue

@ -13,6 +13,7 @@ import {
useVModel,
watch,
} from '#imports'
import { IsPublicInj } from '~/context'
const props = defineProps<{ modelValue: boolean }>()
@ -38,6 +39,8 @@ const {
const { addLTARRef, isNew } = useSmartsheetRowStoreOrThrow()
const isPublic = inject(IsPublicInj, ref(false))
const linkRow = async (row: Record<string, any>) => {
if (isNew.value) {
addLTARRef(row, column?.value as ColumnType)
@ -105,7 +108,7 @@ const newRowState = computed(() => {
></a-input>
<div class="flex-1" />
<MdiReload class="cursor-pointer text-gray-500 nc-reload" @click="loadChildrenExcludedList" />
<a-button type="primary" size="small" @click="expandedFormDlg = true">Add new record</a-button>
<a-button v-if="!isPublic" type="primary" size="small" @click="expandedFormDlg = true">Add new record</a-button>
</div>
<template v-if="childrenExcludedList?.pageInfo?.totalRows">
<div class="flex-1 overflow-auto min-h-0">

1
packages/nc-gui-v2/composables/index.ts

@ -21,3 +21,4 @@ export * from './useColumnCreateStore'
export * from './useSmartsheetStore'
export * from './useLTARStore'
export * from './useExpandedFormStore'
export * from './useSharedFormViewStore'

28
packages/nc-gui-v2/composables/useLTARStore.ts

@ -2,6 +2,7 @@ import type { ColumnType, LinkToAnotherRecordType, PaginatedType, TableType } fr
import type { ComputedRef, Ref } from 'vue'
import { Modal, message } from 'ant-design-vue'
import {
IsPublicInj,
NOCO,
computed,
extractSdkResponseErrorMsg,
@ -13,6 +14,7 @@ import {
useProject,
} from '#imports'
import type { Row } from '~/composables'
import { SharedViewPasswordInj } from '~/context'
interface DataApiResponse {
list: Record<string, any>
@ -26,6 +28,7 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const { metas, getMeta } = useMetas()
const { project } = useProject()
const { $api } = useNuxtApp()
const sharedViewPassword = inject(SharedViewPasswordInj, ref(null))
const childrenExcludedList = ref<DataApiResponse | undefined>()
const childrenList = ref<DataApiResponse | undefined>()
const childrenExcludedListPagination = reactive({
@ -39,6 +42,8 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
size: 10,
})
const isPublic: boolean = $(inject(IsPublicInj, ref(false)))
const colOptions = $computed(() => column?.value.colOptions as LinkToAnotherRecordType)
// getters
@ -79,8 +84,29 @@ const [useProvideLTARStore, useLTARStore] = useInjectionState(
const loadChildrenExcludedList = async () => {
try {
if (isPublic) {
const route = useRoute()
childrenExcludedList.value = await $api.public.dataRelationList(
route.params.viewId as string,
column?.value?.id,
{},
{
headers: {
'xc-password': sharedViewPassword.value,
},
query: {
limit: childrenExcludedListPagination.size,
offset: childrenExcludedListPagination.size * (childrenExcludedListPagination.page - 1),
where:
childrenExcludedListPagination.query &&
`(${relatedTablePrimaryValueProp.value},like,${childrenExcludedListPagination.query})`,
fields: [relatedTablePrimaryValueProp.value, ...relatedTablePrimaryKeyProps.value],
} as any,
},
)
/** if new row load all records */
if (isNewRow?.value) {
} else if (isNewRow?.value) {
childrenExcludedList.value = await $api.dbTableRow.list(
NOCO,
project.value.id as string,

197
packages/nc-gui-v2/composables/useSharedFormViewStore.ts

@ -0,0 +1,197 @@
import useVuelidate from '@vuelidate/core'
import { minLength, required } from '@vuelidate/validators'
import { message } from 'ant-design-vue'
import type { ColumnType, FormType, LinkToAnotherRecordType, TableType, ViewType } from 'nocodb-sdk'
import { ErrorMessages, RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { SharedViewPasswordInj } from '~/context'
import { extractSdkResponseErrorMsg } from '~/utils'
import { useInjectionState, useMetas } from '#imports'
const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((sharedViewId: string) => {
const progress = ref(false)
const notFound = ref(false)
const submitted = ref(false)
const passwordDlg = ref(false)
const password = ref<string | null>(null)
const secondsRemain = ref(0)
provide(SharedViewPasswordInj, password)
const sharedView = ref<ViewType>()
const sharedFormView = ref<FormType>()
const meta = ref<TableType>()
const columns = ref<(ColumnType & { required?: boolean; show?: boolean })[]>()
const { $api } = useNuxtApp()
const { metas, setMeta } = useMetas()
const formState = ref({})
const { state: additionalState } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
ref({
row: formState,
rowMeta: { new: true },
oldRow: {},
}),
)
const formColumns = computed(() =>
columns?.value?.filter((c) => c.show)?.filter((col) => !isVirtualCol(col) || col.uidt === UITypes.LinkToAnotherRecord),
)
const loadSharedView = async () => {
try {
const viewMeta = await $api.public.sharedViewMetaGet(sharedViewId, {
headers: {
'xc-password': password.value,
},
})
passwordDlg.value = false
sharedView.value = viewMeta
sharedFormView.value = viewMeta.view
meta.value = viewMeta.model
columns.value = viewMeta.model?.columns
setMeta(viewMeta.model)
const relatedMetas = { ...viewMeta.relatedMetas }
Object.keys(relatedMetas).forEach((key) => setMeta(relatedMetas[key]))
} catch (e: any) {
if (e.response && e.response.status === 404) {
notFound.value = true
} else if ((await extractSdkResponseErrorMsg(e)) === ErrorMessages.INVALID_SHARED_VIEW_PASSWORD) {
passwordDlg.value = true
}
}
}
const validators = computed(() => {
const obj: any = {
localState: {},
virtual: {},
}
for (const column of formColumns?.value ?? []) {
if (
!isVirtualCol(column) &&
((column.rqd && !column.cdf) || (column.pk && !(column.ai || column.cdf)) || (column as any).required)
) {
obj.localState[column.title!] = { required }
} else if (
column.uidt === UITypes.LinkToAnotherRecord &&
column.colOptions &&
(column.colOptions as LinkToAnotherRecordType).type === RelationTypes.BELONGS_TO
) {
const col = columns.value?.find((c) => c.id === (column?.colOptions as LinkToAnotherRecordType)?.fk_child_column_id)
if ((col && col.rqd && !col.cdf) || column.required) {
if (col) {
obj.virtual[column.title!] = { required }
}
}
} else if (isVirtualCol(column) && column.required) {
obj.virtual[column.title!] = {
minLength: minLength(1),
required,
}
}
}
return obj
})
const v$ = useVuelidate(
validators,
computed(() => ({ localState: formState?.value, virtual: additionalState?.value })),
)
const submitForm = async () => {
try {
if (!(await v$.value?.$validate())) {
return
}
progress.value = true
const data: Record<string, any> = { ...(formState?.value ?? {}), ...(additionalState?.value || {}) }
const attachment: Record<string, any> = {}
for (const col of metas?.value?.[sharedView?.value?.fk_model_id as string]?.columns ?? []) {
if (col.uidt === UITypes.Attachment) {
attachment[`_${col.title}`] = data[col.title!]
delete data[col.title!]
}
}
await $api.public.dataCreate(
sharedView?.value?.uuid as string,
{
data,
...attachment,
},
{
headers: {
'xc-password': password.value,
},
},
)
submitted.value = true
progress.value = false
await message.success(sharedFormView.value?.success_msg || 'Saved successfully.')
} catch (e: any) {
console.log(e)
await message.error(await extractSdkResponseErrorMsg(e))
}
progress.value = false
}
/** reset form if show_blank_form is true */
watch(submitted, (nextVal: boolean) => {
if (nextVal && sharedFormView.value?.show_blank_form) {
secondsRemain.value = 5
const intvl = setInterval(() => {
secondsRemain.value = secondsRemain.value - 1
if (secondsRemain.value < 0) {
submitted.value = false
clearInterval(intvl)
}
}, 1000)
}
/** reset form state and validation */
if (!nextVal) {
additionalState.value = {}
formState.value = {}
v$.value?.$reset()
}
})
return {
sharedView,
sharedFormView,
loadSharedView,
columns,
submitForm,
progress,
meta,
validators,
v$,
formColumns,
formState,
notFound,
password,
submitted,
secondsRemain,
passwordDlg,
}
}, 'expanded-form-store')
export { useProvideSharedFormStore }
export function useSharedFormStoreOrThrow() {
const sharedFormStore = useSharedFormStore()
if (sharedFormStore == null) throw new Error('Please call `useProvideSharedFormStore` on the appropriate parent component')
return sharedFormStore
}

9
packages/nc-gui-v2/composables/useSmartsheetStore.ts

@ -3,7 +3,8 @@ import type { TableType, ViewType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { computed, reactive, useInjectionState, useNuxtApp, useProject, useTemplateRefsList } from '#imports'
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState((view: Ref<ViewType>, meta: Ref<TableType>) => {
const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
(view: Ref<ViewType>, meta: Ref<TableType>, shared = false) => {
const { $api } = useNuxtApp()
const { sqlUi } = useProject()
@ -21,6 +22,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState((view:
const isPkAvail = computed(() => meta?.value?.columns?.some((c) => c.pk))
const isGrid = computed(() => (view?.value as any)?.type === ViewTypes.GRID)
const isForm = computed(() => (view?.value as any)?.type === ViewTypes.FORM)
const isSharedForm = computed(() => isForm?.value && shared)
const isGallery = computed(() => (view?.value as any)?.type === ViewTypes.GALLERY)
const xWhere = computed(() => {
let where
@ -48,8 +50,11 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState((view:
isGrid,
isGallery,
cellRefs,
isSharedForm,
}
}, 'smartsheet-store')
},
'smartsheet-store',
)
export { useProvideSmartsheetStore }

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

@ -24,3 +24,4 @@ export const ReloadViewDataHookInj: InjectionKey<EventHook<void>> = Symbol('relo
export const FieldsInj: InjectionKey<Ref<any[]>> = Symbol('fields-injection')
export const ViewListInj: InjectionKey<Ref<ViewType[]>> = Symbol('view-list-injection')
export const EditModeInj: InjectionKey<Ref<boolean>> = Symbol('edit-mode-injection')
export const SharedViewPasswordInj: InjectionKey<Ref<string | null>> = Symbol('shared-view-password-injection')

9
packages/nc-gui-v2/layouts/base.vue

@ -22,7 +22,10 @@ const logout = () => {
<a-layout class="!flex-col">
<Transition name="layout">
<a-layout-header v-if="signedIn" class="flex !bg-primary items-center text-white pl-4 pr-5 shadow-lg">
<a-layout-header
v-if="!route.meta.public && signedIn"
class="flex !bg-primary items-center text-white pl-4 pr-5 shadow-lg"
>
<div
v-if="
route.name === 'index' || route.name === 'project-index-create' || route.name === 'project-index-create-external'
@ -48,7 +51,7 @@ const logout = () => {
<GeneralShareBaseButton />
<a-tooltip placement="bottom">
<template #title> Switch language </template>
<template #title> Switch language</template>
<div class="flex pr-4 items-center">
<GeneralLanguage class="cursor-pointer text-2xl" />
@ -88,7 +91,7 @@ const logout = () => {
</Transition>
<a-tooltip placement="bottom">
<template #title> Switch language </template>
<template #title> Switch language</template>
<Transition name="layout">
<div v-if="!signedIn" class="nc-lang-btn">

3
packages/nc-gui-v2/middleware/auth.global.ts

@ -23,6 +23,9 @@ import { useGlobal } from '#imports'
export default defineNuxtRouteMiddleware((to, from) => {
const state = useGlobal()
/** if public allow */
if (to.meta.public) return
/** if shred base allow without validating */
if (to.params?.projectType === 'base') return

100
packages/nc-gui-v2/package-lock.json generated

@ -6,6 +6,8 @@
"": {
"dependencies": {
"@ckpack/vue-color": "^1.2.0",
"@vuelidate/core": "^2.0.0-alpha.44",
"@vuelidate/validators": "^2.0.0-alpha.31",
"@vueuse/core": "^9.0.2",
"@vueuse/integrations": "^9.0.2",
"ant-design-vue": "^3.2.10",
@ -2862,6 +2864,72 @@
"vue": "^3.0.1"
}
},
"node_modules/@vuelidate/core": {
"version": "2.0.0-alpha.44",
"resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.0-alpha.44.tgz",
"integrity": "sha512-3DlCe3E0RRXbB+OfPacUetKhLmXzmnjeHkzjnbkc03p06mKm6h9pXR5pd6Mv4s4tus4sieuKDb2YWNmKK6rQeA==",
"dependencies": {
"vue-demi": "^0.13.4"
}
},
"node_modules/@vuelidate/core/node_modules/vue-demi": {
"version": "0.13.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.8.tgz",
"integrity": "sha512-Vy1zbZhCOdsmvGR6tJhAvO5vhP7eiS8xkbYQSoVa7o6KlIy3W8Rc53ED4qI4qpeRDjv3mLfXSEpYU6Yq4pgXRg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vuelidate/validators": {
"version": "2.0.0-alpha.31",
"resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.0-alpha.31.tgz",
"integrity": "sha512-+MFA9nZ7Y9zCpq383/voPDk/hiAmu6KqiJJhLOYB/FmrUPVoyKnuKnI9Bwiq8ok9GZlVkI8BnIrKPKGj9QpwiQ==",
"dependencies": {
"vue-demi": "^0.13.4"
}
},
"node_modules/@vuelidate/validators/node_modules/vue-demi": {
"version": "0.13.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.8.tgz",
"integrity": "sha512-Vy1zbZhCOdsmvGR6tJhAvO5vhP7eiS8xkbYQSoVa7o6KlIy3W8Rc53ED4qI4qpeRDjv3mLfXSEpYU6Yq4pgXRg==",
"hasInstallScript": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vuetify/loader-shared": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@vuetify/loader-shared/-/loader-shared-1.5.0.tgz",
@ -17366,6 +17434,38 @@
"dev": true,
"requires": {}
},
"@vuelidate/core": {
"version": "2.0.0-alpha.44",
"resolved": "https://registry.npmjs.org/@vuelidate/core/-/core-2.0.0-alpha.44.tgz",
"integrity": "sha512-3DlCe3E0RRXbB+OfPacUetKhLmXzmnjeHkzjnbkc03p06mKm6h9pXR5pd6Mv4s4tus4sieuKDb2YWNmKK6rQeA==",
"requires": {
"vue-demi": "^0.13.4"
},
"dependencies": {
"vue-demi": {
"version": "0.13.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.8.tgz",
"integrity": "sha512-Vy1zbZhCOdsmvGR6tJhAvO5vhP7eiS8xkbYQSoVa7o6KlIy3W8Rc53ED4qI4qpeRDjv3mLfXSEpYU6Yq4pgXRg==",
"requires": {}
}
}
},
"@vuelidate/validators": {
"version": "2.0.0-alpha.31",
"resolved": "https://registry.npmjs.org/@vuelidate/validators/-/validators-2.0.0-alpha.31.tgz",
"integrity": "sha512-+MFA9nZ7Y9zCpq383/voPDk/hiAmu6KqiJJhLOYB/FmrUPVoyKnuKnI9Bwiq8ok9GZlVkI8BnIrKPKGj9QpwiQ==",
"requires": {
"vue-demi": "^0.13.4"
},
"dependencies": {
"vue-demi": {
"version": "0.13.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.8.tgz",
"integrity": "sha512-Vy1zbZhCOdsmvGR6tJhAvO5vhP7eiS8xkbYQSoVa7o6KlIy3W8Rc53ED4qI4qpeRDjv3mLfXSEpYU6Yq4pgXRg==",
"requires": {}
}
}
},
"@vuetify/loader-shared": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@vuetify/loader-shared/-/loader-shared-1.5.0.tgz",

2
packages/nc-gui-v2/package.json

@ -12,6 +12,8 @@
},
"dependencies": {
"@ckpack/vue-color": "^1.2.0",
"@vuelidate/core": "^2.0.0-alpha.44",
"@vuelidate/validators": "^2.0.0-alpha.31",
"@vueuse/core": "^9.0.2",
"@vueuse/integrations": "^9.0.2",
"ant-design-vue": "^3.2.10",

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

@ -1,39 +1,31 @@
<script setup lang="ts">
import type { Ref } from 'vue'
import type { TableType } from 'nocodb-sdk'
import { ActiveViewInj, FieldsInj, IsPublicInj, MetaInj, ReloadViewDataHookInj, useRoute } from '#imports'
import type { TableType } from 'nocodb-sdk/build/main'
import { useProvideSharedFormStore } from '~/composables/useSharedFormViewStore'
import { IsFormInj, IsPublicInj, MetaInj, ReloadViewDataHookInj } from '~/context'
import { createEventHook, definePageMeta, provide, ref, useProvideSmartsheetStore, useRoute } from '#imports'
definePageMeta({
requiresAuth: false,
public: true,
})
const route = useRoute()
const reloadEventHook = createEventHook<void>()
const { sharedView, loadSharedView, meta, formColumns } = useSharedView()
await loadSharedView(route.params.viewId as string)
const { loadSharedView, sharedView, meta, notFound } = useProvideSharedFormStore(route.params.viewId as string)
provide(ReloadViewDataHookInj, reloadEventHook)
provide(MetaInj, meta)
provide(ActiveViewInj, sharedView)
provide(FieldsInj, formColumns)
provide(IsPublicInj, ref(true))
await loadSharedView()
if (!notFound.value) {
provide(ReloadViewDataHookInj, reloadEventHook)
provide(MetaInj, meta)
provide(IsPublicInj, ref(true))
provide(IsFormInj, ref(true))
useProvideSmartsheetStore(sharedView as Ref<TableType>, meta)
useProvideSmartsheetStore(sharedView as Ref<TableType>, meta as Ref<TableType>, true)
}
</script>
<template>
<NuxtLayout id="content" class="flex">
<div class="nc-container flex flex-col h-full mt-2 px-6">
<SharedViewForm />
</div>
</NuxtLayout>
</template>
<style scoped>
.nc-container {
height: calc(100% - var(--header-height));
flex: 1 1 100%;
}
</style>

38
packages/nocodb-sdk/src/lib/Api.ts

@ -130,6 +130,7 @@ export interface ViewType {
order?: number;
fk_model_id?: string;
slug?: string;
uuid?: string;
show_system_fields?: boolean;
lock_type?: 'collaborative' | 'locked' | 'personal';
}
@ -3062,32 +3063,41 @@ export class Api<
}),
/**
* No description
* @description Read project details
*
* @tags Public
* @name SharedViewMetaGet
* @request GET:/api/v1/db/public/shared-view/{sharedViewUuid}/meta
* @response `200` `object` OK
* @name SharedBaseGet
* @request GET:/api/v1/db/public/shared-base/{sharedBaseUuid}/meta
* @response `200` `{ project_id?: string }` OK
*/
sharedViewMetaGet: (sharedViewUuid: string, params: RequestParams = {}) =>
this.request<object, any>({
path: `/api/v1/db/public/shared-view/${sharedViewUuid}/meta`,
sharedBaseGet: (sharedBaseUuid: string, params: RequestParams = {}) =>
this.request<{ project_id?: string }, any>({
path: `/api/v1/db/public/shared-base/${sharedBaseUuid}/meta`,
method: 'GET',
format: 'json',
...params,
}),
/**
* @description Read project details
* No description
*
* @tags Public
* @name SharedBaseGet
* @request GET:/api/v1/db/public/shared-base/{sharedBaseUuid}/meta
* @response `200` `{ project_id?: string }` OK
* @name SharedViewMetaGet
* @request GET:/api/v1/db/public/shared-view/{sharedViewUuid}/meta
* @response `200` `(ViewType & { relatedMetas?: any, client?: string, columns?: ((GridColumnType | FormColumnType | GalleryColumnType) & ColumnType), model?: TableType } & { view?: (FormType | GridType | GalleryType) })` OK
*/
sharedBaseGet: (sharedBaseUuid: string, params: RequestParams = {}) =>
this.request<{ project_id?: string }, any>({
path: `/api/v1/db/public/shared-base/${sharedBaseUuid}/meta`,
sharedViewMetaGet: (sharedViewUuid: string, params: RequestParams = {}) =>
this.request<
ViewType & {
relatedMetas?: any;
client?: string;
columns?: (GridColumnType | FormColumnType | GalleryColumnType) &
ColumnType;
model?: TableType;
} & { view?: FormType | GridType | GalleryType },
any
>({
path: `/api/v1/db/public/shared-view/${sharedViewUuid}/meta`,
method: 'GET',
format: 'json',
...params,

5
packages/nocodb/src/lib/db/sql-data-mapper/lib/sql/BaseModelSqlv2.ts

@ -1552,8 +1552,9 @@ class BaseModelSqlv2 {
switch (colOptions.type) {
case RelationTypes.BELONGS_TO:
{
const parentCol = await colOptions.getChildColumn();
insertObj[parentCol.column_name] =
const childCol = await colOptions.getChildColumn();
const parentCol = await colOptions.getParentColumn();
insertObj[childCol.column_name] =
nestedData?.[parentCol.title];
}
break;

106
scripts/sdk/swagger.json

@ -4363,28 +4363,22 @@
"description": ""
}
},
"/api/v1/db/public/shared-view/{sharedViewUuid}/meta": {
"/api/v1/db/public/shared-base/{sharedBaseUuid}/meta": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "sharedViewUuid",
"name": "sharedBaseUuid",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "",
"operationId": "public-shared-view-meta-get",
"operationId": "public-shared-base-get",
"description": "Read project details",
"parameters": [],
"responses": {
"200": {
"description": "OK",
@ -4392,58 +4386,117 @@
"application/json": {
"schema": {
"type": "object",
"properties": {}
"properties": {
"project_id": {
"type": "string"
}
}
}
},
"application/xml": {
"schema": {}
}
}
}
},
"tags": [
"Public"
],
"description": "",
"parameters": []
]
}
},
"/api/v1/db/public/shared-base/{sharedBaseUuid}/meta": {
"/api/v1/db/public/shared-view/{sharedViewUuid}/meta": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "sharedBaseUuid",
"name": "sharedViewUuid",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"in": "header",
"name": "xc-password",
"description": "Shared view password"
}
],
"get": {
"summary": "",
"operationId": "public-shared-base-get",
"description": "Read project details",
"parameters": [],
"operationId": "public-shared-view-meta-get",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/View"
},
{
"type": "object",
"properties": {
"project_id": {
"relatedMetas": {},
"client": {
"type": "string"
},
"columns": {
"allOf": [
{
"oneOf": [
{
"$ref": "#/components/schemas/GridColumn"
},
{
"$ref": "#/components/schemas/FormColumn"
},
{
"$ref": "#/components/schemas/GalleryColumn"
}
]
},
{
"$ref": "#/components/schemas/Column"
}
]
},
"model": {
"$ref": "#/components/schemas/Table"
}
}
},
{
"type": "object",
"properties": {
"view": {
"oneOf": [
{
"$ref": "#/components/schemas/Form"
},
{
"$ref": "#/components/schemas/Grid"
},
{
"$ref": "#/components/schemas/Gallery"
}
]
}
}
}
]
}
},
"application/xml": {
"schema": {}
}
}
}
},
"tags": [
"Public"
]
],
"description": "",
"parameters": []
}
},
"/api/v1/db/meta/audits/comments": {
@ -6093,6 +6146,9 @@
"slug": {
"type": "string"
},
"uuid": {
"type": "string"
},
"show_system_fields": {
"type": "boolean"
},

Loading…
Cancel
Save