Browse Source

Merge pull request #7786 from nocodb/nc-feat/prefill-shared-form

Nc feat: Prefill shared form by query params
pull/7830/head
Raju Udava 4 months ago committed by GitHub
parent
commit
f1a0611cf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      packages/nc-gui/components/cell/Json.vue
  2. 2
      packages/nc-gui/components/cell/MultiSelect.vue
  3. 2
      packages/nc-gui/components/cell/SingleSelect.vue
  4. 1
      packages/nc-gui/components/cell/User.vue
  5. 156
      packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue
  6. 6
      packages/nc-gui/components/general/ColorPicker.vue
  7. 12
      packages/nc-gui/components/monaco/Editor.vue
  8. 10
      packages/nc-gui/components/smartsheet/Cell.vue
  9. 122
      packages/nc-gui/components/smartsheet/Form.vue
  10. 252
      packages/nc-gui/composables/useSharedFormViewStore.ts
  11. 2
      packages/nc-gui/composables/useViewData.ts
  12. 14
      packages/nc-gui/lang/en.json
  13. 6
      packages/nc-gui/lib/enums.ts
  14. 4
      packages/nc-gui/lib/types.ts
  15. 26
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue
  16. 69
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  17. 68
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  18. 3
      packages/nc-gui/store/views.ts
  19. 11
      packages/nc-gui/utils/cell.ts
  20. 6
      packages/nc-gui/utils/dataUtils.ts

1
packages/nc-gui/components/cell/Json.vue

@ -184,6 +184,7 @@ watch(isExpanded, () => {
:class="{ 'expanded-editor': isExpanded, 'editor': !isExpanded }"
:hide-minimap="true"
:disable-deep-compare="true"
:auto-focus="!isForm"
@update:model-value="localValue = $event"
@keydown.enter.stop
/>

2
packages/nc-gui/components/cell/MultiSelect.vue

@ -394,7 +394,7 @@ const onFocus = () => {
@click="toggleMenu"
>
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-checkbox-group v-model:value="vModel" class="nc-field-layout-list">
<a-checkbox-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list">
<a-checkbox
v-for="op of options"
:key="op.title"

2
packages/nc-gui/components/cell/SingleSelect.vue

@ -315,7 +315,7 @@ const onFocus = () => {
@keydown.enter.stop.prevent="toggleMenu"
>
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-radio-group v-model:value="vModel" class="nc-field-layout-list">
<a-radio-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list">
<a-radio
v-for="op of options"
:key="op.title"

1
packages/nc-gui/components/cell/User.vue

@ -323,6 +323,7 @@ const filterOption = (input: string, option: any) => {
<component
:is="isMultiple ? CheckboxGroup : RadioGroup"
v-model:value="vModelListLayout"
:disabled="readOnly || !editAllowed"
class="nc-field-layout-list"
@update:value="
(value) => {

156
packages/nc-gui/components/dlg/share-and-collaborate/SharePage.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ColumnType, KanbanType, ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
import { useMetas } from '#imports'
import { PreFilledMode, useMetas } from '#imports'
const { view: _view, $api } = useSmartsheetStoreOrThrow()
const { $e } = useNuxtApp()
@ -44,6 +44,10 @@ const activeView = computed<(ViewType & { meta: object & Record<string, any> })
},
})
const isPublicShared = computed(() => {
return !!activeView.value?.uuid
})
const url = computed(() => {
return sharedViewUrl() ?? ''
})
@ -145,6 +149,37 @@ const surveyMode = computed({
},
})
const formPreFill = computed({
get: () => ({
preFillEnabled: parseProp(activeView.value?.meta)?.preFillEnabled ?? false,
preFilledMode: parseProp(activeView.value?.meta)?.preFilledMode || PreFilledMode.Default,
}),
set: (value) => {
if (!activeView.value?.meta) return
if (formPreFill.value.preFillEnabled !== value.preFillEnabled) {
$e(`a:view:share:prefilled-mode-${value.preFillEnabled ? 'enabled' : 'disabled'}`)
}
if (formPreFill.value.preFilledMode !== value.preFilledMode) {
$e(`a:view:share:${value.preFilledMode}-prefilled-mode`)
}
activeView.value.meta = {
...activeView.value.meta,
...value,
}
savePreFilledMode()
},
})
const handleChangeFormPreFill = (value: { preFillEnabled?: boolean; preFilledMode?: PreFilledMode }) => {
formPreFill.value = {
...formPreFill.value,
...value,
}
}
function sharedViewUrl() {
if (!activeView.value) return
@ -177,7 +212,11 @@ function sharedViewUrl() {
dashboardUrl1 = `${baseUrl}${appInfo.value?.dashboardPath}`
}
return encodeURI(`${dashboardUrl1}#/nc/${viewType}/${activeView.value.uuid}`)
return encodeURI(
`${dashboardUrl1}#/nc/${viewType}/${activeView.value.uuid}${surveyMode.value ? '/survey' : ''}${
viewStore.preFillFormSearchParams && formPreFill.value.preFillEnabled ? `?${viewStore.preFillFormSearchParams}` : ''
}`,
)
}
const toggleViewShare = async () => {
@ -254,9 +293,11 @@ async function updateSharedView() {
return true
}
const isPublicShared = computed(() => {
return !!activeView.value?.uuid
})
async function savePreFilledMode() {
await updateSharedView()
}
watchEffect(() => {})
</script>
<template>
@ -279,7 +320,7 @@ const isPublicShared = computed(() => {
<GeneralCopyUrl v-model:url="url" />
</div>
<div class="flex flex-col justify-between mt-1 py-2 px-3 bg-gray-50 rounded-md">
<div class="flex flex-row justify-between">
<div class="flex flex-row items-center justify-between">
<div class="flex text-black">{{ $t('activity.restrictAccessWithPassword') }}</div>
<a-switch
v-e="['c:share:view:password:toggle']"
@ -287,6 +328,7 @@ const isPublicShared = computed(() => {
:loading="isUpdating.password"
class="share-password-toggle !mt-0.25"
data-testid="share-password-toggle"
size="small"
@click="togglePasswordProtected"
/>
</div>
@ -307,13 +349,9 @@ const isPublicShared = computed(() => {
<div
v-if="
activeView &&
(activeView.type === ViewTypes.GRID ||
activeView.type === ViewTypes.KANBAN ||
activeView.type === ViewTypes.GALLERY ||
activeView.type === ViewTypes.MAP ||
activeView.type === ViewTypes.CALENDAR)
[ViewTypes.GRID, ViewTypes.KANBAN, ViewTypes.GALLERY, ViewTypes.MAP, ViewTypes.CALENDAR].includes(activeView.type)
"
class="flex flex-row justify-between"
class="flex flex-row items-center justify-between"
>
<div class="flex text-black">{{ $t('activity.allowDownload') }}</div>
<a-switch
@ -322,27 +360,81 @@ const isPublicShared = computed(() => {
:loading="isUpdating.download"
class="public-password-toggle !mt-0.25"
data-testid="share-download-toggle"
size="small"
/>
</div>
<div v-if="activeView?.type === ViewTypes.FORM" class="flex flex-row justify-between">
<div class="text-black">{{ $t('activity.surveyMode') }}</div>
<template v-if="activeView?.type === ViewTypes.FORM">
<div class="flex flex-row items-center justify-between">
<div class="text-black flex items-center space-x-1">
<div>
{{ $t('activity.surveyMode') }}
</div>
<NcTooltip>
<template #title> {{ $t('tooltip.surveyFormInfo') }}</template>
<GeneralIcon icon="info" class="text-gray-600 cursor-pointer"></GeneralIcon>
</NcTooltip>
</div>
<a-switch
v-model:checked="surveyMode"
v-e="['c:share:view:surver-mode:toggle']"
data-testid="nc-modal-share-view__surveyMode"
size="small"
>
</a-switch>
</div>
<div v-if="!isEeUI" class="flex flex-row items-center justify-between">
<div class="text-black">{{ $t('activity.rtlOrientation') }}</div>
<a-switch
v-model:checked="withRTL"
v-e="['c:share:view:rtl-orientation:toggle']"
data-testid="nc-modal-share-view__RTL"
size="small"
>
</a-switch>
</div>
</template>
</div>
<div
v-if="activeView?.type === ViewTypes.FORM"
class="nc-pre-filled-mode-wrapper flex flex-col justify-between gap-y-3 mt-1 py-2 px-3 bg-gray-50 rounded-md"
>
<div class="flex flex-row items-center justify-between">
<div class="text-black flex items-center space-x-1">
<div>
{{ $t('activity.preFilledFields.title') }}
</div>
<NcTooltip>
<template #title>
<div class="text-center">
{{ $t('tooltip.preFillFormInfo') }}
</div>
</template>
<GeneralIcon icon="info" class="text-gray-600 cursor-pointer"></GeneralIcon>
</NcTooltip>
</div>
<a-switch
v-model:checked="surveyMode"
v-e="['c:share:view:surver-mode:toggle']"
data-testid="nc-modal-share-view__surveyMode"
>
</a-switch>
</div>
<div v-if="activeView?.type === ViewTypes.FORM && !isEeUI" class="flex flex-row justify-between">
<div class="text-black">{{ $t('activity.rtlOrientation') }}</div>
<a-switch
v-model:checked="withRTL"
v-e="['c:share:view:rtl-orientation:toggle']"
data-testid="nc-modal-share-view__RTL"
:checked="formPreFill.preFillEnabled"
data-testid="nc-modal-share-view__preFill"
size="small"
@update:checked="handleChangeFormPreFill({ preFillEnabled: $event as boolean })"
>
</a-switch>
</div>
<a-radio-group
v-if="formPreFill.preFillEnabled"
:value="formPreFill.preFilledMode"
class="nc-modal-share-view-preFillMode"
data-testid="nc-modal-share-view__preFillMode"
@update:value="handleChangeFormPreFill({ preFilledMode: $event })"
>
<a-radio v-for="mode of Object.values(PreFilledMode)" :key="mode" :value="mode">
<div class="flex-1">{{ $t(`activity.preFilledFields.${mode}`) }}</div>
</a-radio>
</a-radio-group>
</div>
</template>
</div>
@ -367,4 +459,18 @@ const isPublicShared = computed(() => {
line-height: 1rem !important;
}
}
.nc-modal-share-view-preFillMode {
@apply flex flex-col;
.ant-radio-wrapper {
@apply !m-0 !flex !items-center w-full px-2 py-1 rounded-lg hover:bg-gray-100;
.ant-radio {
@apply !top-0;
}
.ant-radio + span {
@apply !flex !pl-4;
}
}
}
</style>

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

@ -104,15 +104,11 @@ watch(picked, (n, _o) => {
-webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: white;
}
.color-selector:not(.new-design):hover {
.color-selector:hover {
filter: brightness(90%);
-webkit-filter: brightness(90%);
}
.color-selector.new-design:hover {
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
}
.color-selector.selected:not(.new-design) {
filter: brightness(90%);
-webkit-filter: brightness(90%);

12
packages/nc-gui/components/monaco/Editor.vue

@ -12,12 +12,20 @@ interface Props {
validate?: boolean
disableDeepCompare?: boolean
readOnly?: boolean
autoFocus?: boolean
}
const { hideMinimap, lang = 'json', validate = true, disableDeepCompare = false, modelValue, readOnly } = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
lang: 'json',
validate: true,
disableDeepCompare: false,
autoFocus: true,
})
const emits = defineEmits(['update:modelValue'])
const { hideMinimap, lang, validate, disableDeepCompare, modelValue, readOnly, autoFocus } = props
const vModel = computed<string>({
get: () => {
if (typeof modelValue === 'object') {
@ -120,7 +128,7 @@ onMounted(async () => {
}
})
if (!isDrawerOrModalExist()) {
if (!isDrawerOrModalExist() && autoFocus) {
// auto focus on json cells only
editor.focus()
}

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

@ -29,6 +29,7 @@ import {
isJSON,
isManualSaved,
isMultiSelect,
isNumericFieldType,
isPercent,
isPhoneNumber,
isPrimary,
@ -142,14 +143,7 @@ const navigate = (dir: NavigateDir, e: KeyboardEvent) => {
}
const isNumericField = computed(() => {
return (
isInt(column.value, abstractType.value) ||
isFloat(column.value, abstractType.value) ||
isDecimal(column.value) ||
isCurrency(column.value) ||
isPercent(column.value) ||
isDuration(column.value)
)
return isNumericFieldType(column.value, abstractType.value)
})
// disable contexxtmenu event propagation when cell is in

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

@ -31,7 +31,6 @@ import {
onClickOutside,
parseProp,
provide,
reactive,
ref,
useDebounceFn,
useEventListener,
@ -42,7 +41,7 @@ import {
useRoles,
useViewColumnsOrThrow,
useViewData,
watch,
useViewsStore,
} from '#imports'
import type { ImageCropperConfig } from '~/lib'
@ -72,8 +71,6 @@ const enum NcForm {
const { isMobileMode, user } = useGlobal()
const formRef = ref()
const { $api, $e } = useNuxtApp()
const { isUIAllowed } = useRoles()
@ -82,7 +79,9 @@ const { base } = storeToRefs(useBase())
const { getPossibleAttachmentSrc } = useAttachment()
let formState = reactive<Record<string, any>>({})
const formRef = ref()
const formState = ref<Record<string, any>>({})
const secondsRemain = ref(0)
@ -98,6 +97,8 @@ const isPublic = inject(IsPublicInj, ref(false))
const { loadFormView, insertRow, formColumnData, formViewData, updateFormView } = useViewData(meta, view)
const { preFillFormSearchParams } = storeToRefs(useViewsStore())
const reloadEventHook = inject(ReloadViewDataHookInj, createEventHook())
reloadEventHook.on(async () => {
@ -110,7 +111,7 @@ const { fields, showAll, hideAll } = useViewColumnsOrThrow()
const { state, row } = useProvideSmartsheetRowStore(
meta,
ref({
row: formState,
row: formState.value,
oldRow: {},
rowMeta: { new: true },
}),
@ -193,6 +194,22 @@ const updateView = useDebounceFn(
{ maxWait: 2000 },
)
const updatePreFillFormSearchParams = useDebounceFn(() => {
if (isLocked.value || !isUIAllowed('dataInsert')) return
const preFilledData = { ...formState.value, ...state.value }
const searchParams = new URLSearchParams()
for (const c of visibleColumns.value) {
if (c.title && preFilledData[c.title] && !isVirtualCol(c) && !(UITypes.Attachment === c.uidt)) {
searchParams.append(c.title, preFilledData[c.title])
}
}
preFillFormSearchParams.value = searchParams.toString()
}, 250)
async function submitForm() {
if (isLocked.value || !isUIAllowed('dataInsert')) return
@ -206,7 +223,7 @@ async function submitForm() {
}
await insertRow({
row: { ...formState, ...state.value },
row: { ...formState.value, ...state.value },
oldRow: {},
rowMeta: { new: true },
})
@ -217,9 +234,9 @@ async function submitForm() {
async function clearForm() {
if (isLocked.value || !isUIAllowed('dataInsert')) return
formState = reactive<Record<string, any>>({})
formState.value = {}
state.value = {}
await formRef.value.clearValidate()
await formRef.value?.clearValidate()
reloadEventHook.trigger()
}
@ -321,13 +338,6 @@ async function showOrHideColumn(column: Record<string, any>, show: boolean, isSi
await $api.dbView.formColumnUpdate(column.id, column)
fields.value[fieldIndex] = column as any
// await saveOrUpdate(
// {
// ...column,
// show,
// },
// fieldIndex,
// )
reloadEventHook.trigger()
@ -554,6 +564,8 @@ onMounted(async () => {
URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
}
preFillFormSearchParams.value = ''
isLoadingFormView.value = true
await loadFormView()
setFormData()
@ -566,6 +578,7 @@ watch(submitted, (v) => {
const intvl = setInterval(() => {
if (--secondsRemain.value < 0) {
submitted.value = false
clearForm()
clearInterval(intvl)
}
}, 1000)
@ -578,25 +591,35 @@ watch(view, (nextView) => {
}
})
watch([formState, state.value], async () => {
for (const virtualField in state.value) {
if (!formState[virtualField]) {
formState[virtualField] = state.value[virtualField]
watch(
[formState, state],
async () => {
for (const virtualField in state.value) {
formState.value[virtualField] = state.value[virtualField]
}
}
try {
await formRef.value?.validateFields([...Object.keys(formState)])
} catch (e: any) {
e.errorFields.map((f: Record<string, any>) => console.error(f.errors.join(',')))
}
})
updatePreFillFormSearchParams()
try {
await formRef.value?.validateFields([...Object.keys(formState.value)])
} catch (e: any) {
e.errorFields.map((f: Record<string, any>) => console.error(f.errors.join(',')))
}
},
{
deep: true,
},
)
watch(activeRow, (newValue) => {
if (newValue) {
document
.querySelector(`.nc-form-field-item-${newValue?.replaceAll(' ', '')}`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
const field = document.querySelector(`.nc-form-field-item-${CSS.escape(newValue?.replaceAll(' ', ''))}`)
if (field) {
setTimeout(() => {
field?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 50)
}
}
})
@ -647,7 +670,7 @@ useEventListener(
(e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
switch (e.key.toLowerCase()) {
switch (e.key?.toLowerCase()) {
case 's':
if (
cmdOrCtrl &&
@ -736,7 +759,16 @@ useEventListener(
</div>
<div v-if="formViewData.submit_another_form || !isPublic" class="text-right mt-4">
<NcButton type="primary" size="medium" @click="submitted = false">
<NcButton
type="primary"
size="medium"
@click="
() => {
submitted = false
clearForm()
}
"
>
{{ $t('activity.submitAnotherForm') }}
</NcButton>
</div>
@ -1056,7 +1088,7 @@ useEventListener(
</div>
<!-- Field Header -->
<div v-if="activeRow === element.title" class="w-full flex gap-3 items-center px-3 py-2 bg-gray-50">
<div v-if="activeRow === element.title" class="w-full flex gap-3 items-center px-3 py-2 bg-gray-50 border-b-1 border-gray-200">
<component
:is="iconMap.drag"
class="nc-form-field-drag-handler flex-none cursor-move !h-4 !w-4 text-gray-600"
@ -1209,7 +1241,7 @@ useEventListener(
<!-- Field Settings -->
<div
v-if="activeRow === element.title && isSelectTypeCol(element.uidt)"
class="nc-form-field-settings border-t border-gray-200 p-4 lg:p-6 flex flex-col gap-3"
class="nc-form-field-settings border-t border-gray-200 p-4 lg:p-6 flex flex-col gap-3 bg-gray-50"
>
<!-- Layout -->
<div v-if="isSelectTypeCol(element.uidt)">
@ -1231,7 +1263,7 @@ useEventListener(
</div>
<!-- Todo: Show on conditions,... -->
<!-- eslint-disable vue/no-constant-condition -->
<div v-if="false" class="flex items-start gap-3 px-3 py-2 border-1 border-gray-200 rounded-lg">
<div v-if="false" class="flex items-start gap-3 px-3 py-2 border-1 border-gray-200 rounded-lg bg-white">
<a-switch v-e="['a:form-view:field:show-on-condition']" size="small" class="nc-form-switch-focus" />
<div>
<div class="font-medium text-gray-800">{{ $t('labels.showOnConditions') }}</div>
@ -1240,7 +1272,7 @@ useEventListener(
</div>
<!-- Limit options -->
<div v-if="isSelectTypeCol(element.uidt)" class="px-3 py-2 border-1 border-gray-200 rounded-lg">
<div v-if="isSelectTypeCol(element.uidt)" class="px-3 py-2 border-1 border-gray-200 rounded-lg bg-white">
<div class="flex items-center gap-3">
<a-switch
v-model:checked="element.meta.isLimitOption"
@ -1289,6 +1321,7 @@ useEventListener(
>
{{ $t('activity.clearForm') }}
</NcButton>
<NcButton
html-type="submit"
type="primary"
@ -1433,7 +1466,7 @@ useEventListener(
<SmartsheetHeaderCellIcon v-else :column-meta="field" />
<div class="flex-1 flex items-center justify-start max-w-[calc(100%_-_68px)] mr-4">
<div class="w-full flex items-center">
<div class="ml-1 inline-block max-w-1/2">
<div class="ml-1 inline-flex" :class="field.label?.trim() ? 'max-w-1/2' : 'max-w-[95%]'">
<NcTooltip class="truncate text-sm" :disabled="drag" show-on-truncate-only>
<template #title>
<div class="text-center">
@ -1792,21 +1825,21 @@ useEventListener(
.nc-form-field-ghost {
@apply bg-gray-50;
}
:deep(.nc-form-input-required + button):focus {
:deep(.nc-form-input-required + button):focus-visible {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
}
:deep(.nc-form-switch-focus):focus {
:deep(.nc-form-switch-focus):focus-visible {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
}
.nc-form-field-layout {
@apply !flex !items-center w-full space-x-3;
:deep(.ant-radio-wrapper) {
@apply border-1 border-gray-200 rounded-lg !py-2 !px-3 basis-full !mr-0 !items-center;
@apply border-1 border-gray-200 rounded-lg !py-2 !px-3 basis-full !mr-0 !items-center bg-white;
.ant-radio {
@apply !top-0;
&:focus-within .ant-radio-inner {
.ant-radio-input:focus-visible + .ant-radio-inner {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
}
}
@ -1814,12 +1847,9 @@ useEventListener(
}
.nc-form-wrapper {
.ant-switch:focus,
.ant-switch-checked:focus {
.ant-switch:focus-visible,
.ant-switch-checked:focus-visible {
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3366ff;
&:hover {
box-shadow: none;
}
}
}
</style>

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

@ -1,5 +1,6 @@
import useVuelidate from '@vuelidate/core'
import { helpers, minLength, required } from '@vuelidate/validators'
import dayjs from 'dayjs'
import type { Ref } from 'vue'
import type {
BoolType,
@ -7,18 +8,22 @@ import type {
FormColumnType,
FormType,
LinkToAnotherRecordType,
SelectOptionsType,
StringOrNullType,
TableType,
} from 'nocodb-sdk'
import { ErrorMessages, RelationTypes, UITypes, isLinksOrLTAR, isVirtualCol } from 'nocodb-sdk'
import { ErrorMessages, RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { isString } from '@vue/shared'
import { filterNullOrUndefinedObjectProperties } from '~/helpers/parsers/parserHelpers'
import {
PreFilledMode,
SharedViewPasswordInj,
computed,
createEventHook,
extractSdkResponseErrorMsg,
isNumericFieldType,
message,
parseProp,
provide,
ref,
storeToRefs,
@ -48,8 +53,15 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const sharedFormView = ref<FormType>()
const meta = ref<TableType>()
const columns =
ref<(ColumnType & { required?: BoolType; show?: BoolType; label?: StringOrNullType; enable_scanner?: BoolType })[]>()
const columns = ref<
(ColumnType & {
required?: BoolType
show?: BoolType
label?: StringOrNullType
enable_scanner?: BoolType
read_only?: boolean
})[]
>()
const sharedViewMeta = ref<SharedViewMeta>({})
const formResetHook = createEventHook<void>()
@ -58,15 +70,20 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const { metas, setMeta } = useMetas()
const baseStore = useBase()
const { base } = storeToRefs(baseStore)
const { base, sqlUis } = storeToRefs(baseStore)
const basesStore = useBases()
const { basesUser } = storeToRefs(basesStore)
const { t } = useI18n()
const route = useRoute()
const formState = ref<Record<string, any>>({})
const preFilledformState = ref<Record<string, any>>({})
const { state: additionalState } = useProvideSmartsheetRowStore(
meta as Ref<TableType>,
ref({
@ -80,7 +97,11 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
helpers.withMessage(t('msg.error.fieldRequired', { value: fieldName }), required)
const formColumns = computed(() =>
columns.value?.filter((c) => c.show).filter((col) => !isVirtualCol(col) || isLinksOrLTAR(col.uidt)),
columns.value
?.filter((c) => c.show)
.filter(
(col) => !isSystemColumn(col) && col.uidt !== UITypes.SpecificDBType && (!isVirtualCol(col) || isLinksOrLTAR(col.uidt)),
),
)
const loadSharedView = async () => {
@ -107,11 +128,25 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
{} as Record<string, FormColumnType>,
)
columns.value = viewMeta.model?.columns?.map((c) => ({
...c,
meta: { ...parseProp(fieldById[c.id].meta), ...parseProp(c.meta) },
description: fieldById[c.id].description,
}))
columns.value = viewMeta.model?.columns?.map((c) => {
if (
!isSystemColumn(c) &&
!isVirtualCol(c) &&
!isAttachment(c) &&
c.uidt !== UITypes.SpecificDBType &&
c?.title &&
c?.cdf &&
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(c.cdf)
) {
formState.value[c.title] = c.cdf
}
return {
...c,
meta: { ...parseProp(fieldById[c.id].meta), ...parseProp(c.meta) },
description: fieldById[c.id].description,
}
})
const _sharedViewMeta = (viewMeta as any).meta
sharedViewMeta.value = isString(_sharedViewMeta) ? JSON.parse(_sharedViewMeta) : _sharedViewMeta
@ -136,6 +171,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
if (viewMeta.users) {
basesUser.value.set(viewMeta.base_id, viewMeta.users)
}
handlePreFillForm()
} catch (e: any) {
if (e.response && e.response.status === 404) {
notFound.value = true
@ -233,10 +270,203 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
formResetHook.trigger()
additionalState.value = {}
formState.value = {}
formState.value = {
...([PreFilledMode.Locked, PreFilledMode.Hidden].includes(sharedViewMeta.value.preFilledMode)
? preFilledformState.value
: {}),
}
v$.value?.$reset()
}
function handlePreFillForm() {
if (Object.keys(route.query || {}).length && sharedViewMeta.value.preFillEnabled) {
columns.value = (columns.value || []).map((c) => {
const queryParam = route.query[c.title as string] || route.query[encodeURIComponent(c.title as string)]
if (
!c.title ||
!queryParam ||
isSystemColumn(c) ||
isVirtualCol(c) ||
isAttachment(c) ||
c.uidt === UITypes.SpecificDBType
) {
return c
}
const preFillValue = getPreFillValue(c, decodeURIComponent(queryParam as string).trim())
if (preFillValue !== undefined) {
// Prefill form state
formState.value[c.title] = preFillValue
// preFilledformState will be used in clear for to fill the filled data
preFilledformState.value[c.title] = preFillValue
// Update column
switch (sharedViewMeta.value.preFilledMode) {
case PreFilledMode.Hidden: {
c.show = false
break
}
case PreFilledMode.Locked: {
c.read_only = true
break
}
}
}
return c
})
}
}
function getColAbstractType(c: ColumnType) {
return (c?.source_id ? sqlUis.value[c?.source_id] : Object.values(sqlUis.value)[0]).getAbstractType(c)
}
function getPreFillValue(c: ColumnType, value: string) {
let preFillValue: any
switch (c.uidt) {
case UITypes.SingleSelect:
case UITypes.MultiSelect:
case UITypes.User: {
const limitOptions = (parseProp(c.meta).isLimitOption ? parseProp(c.meta).limitOptions || [] : []).reduce((ac, op) => {
if (op?.id) {
ac[op.id] = op
}
return ac
}, {})
const queryOptions = value.split(',')
let options: string[] = []
if ([UITypes.SingleSelect, UITypes.MultiSelect].includes(c.uidt as UITypes)) {
options = ((c.colOptions as SelectOptionsType)?.options || [])
.filter((op) => {
if (
op?.id &&
op?.title &&
queryOptions.includes(op.title) &&
(limitOptions[op.id]
? limitOptions[op.id]?.show
: parseProp(c.meta).isLimitOption
? !(parseProp(c.meta).limitOptions || []).length
: true)
) {
return true
}
return false
})
.map((op) => op.title as string)
if (options.length) {
preFillValue = c.uidt === UITypes.SingleSelect ? options[0] : options.join(',')
}
} else {
options = (meta.value?.base_id ? basesUser.value.get(meta.value.base_id) || [] : [])
.filter((user) => {
if (
user?.id &&
user?.email &&
(queryOptions.includes(user.email) || queryOptions.includes(user.id)) &&
(limitOptions[user.id]
? limitOptions[user.id]?.show
: parseProp(c.meta).isLimitOption
? !(parseProp(c.meta).limitOptions || []).length
: true)
) {
return true
}
return false
})
.map((user) => user.email)
if (options.length) {
preFillValue = !parseProp(c.meta)?.is_multi ? options[0] : options.join(',')
}
}
break
}
case UITypes.Checkbox: {
if (['true', '1'].includes(value.toLowerCase())) {
preFillValue = true
} else if (['false', '0'].includes(value.toLowerCase())) {
preFillValue = false
}
break
}
case UITypes.Rating: {
if (!isNaN(Number(value))) {
preFillValue = Math.min(Math.max(Number(value), 0), parseProp(c.meta).max ?? 5)
}
break
}
case UITypes.URL: {
if (parseProp(c.meta).validate) {
if (isValidURL(value)) {
preFillValue = value
}
} else {
preFillValue = value
}
break
}
case UITypes.Year: {
if (/^\d+$/.test(value)) {
preFillValue = Number(value)
}
break
}
case UITypes.Date: {
const parsedDate = dayjs(value)
if ((parsedDate.isValid() && parsedDate.toISOString() === value) || dayjs(value, 'YYYY-MM-DD').isValid()) {
preFillValue = dayjs(value).format('YYYY-MM-DD')
}
break
}
case UITypes.DateTime: {
const parsedDateTime = dayjs(value)
if (
(parsedDateTime.isValid() && parsedDateTime.toISOString() === value) ||
dayjs(value, 'YYYY-MM-DD HH:mm:ss').isValid()
) {
preFillValue = dayjs(value).utc().format('YYYY-MM-DD HH:mm:ssZ')
}
break
}
case UITypes.Time: {
let parsedTime = dayjs(value)
if (!parsedTime.isValid()) {
parsedTime = dayjs(value, 'HH:mm:ss')
}
if (!parsedTime.isValid()) {
parsedTime = dayjs(`1999-01-01 ${value}`)
}
if (parsedTime.isValid()) {
preFillValue = parsedTime.format(baseStore.isMysql(c.source_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ')
}
break
}
case UITypes.LinkToAnotherRecord:
case UITypes.Links: {
// Todo: create an api which will fetch query params records and then autofill records
break
}
default: {
if (isNumericFieldType(c, getColAbstractType(c))) {
if (!isNaN(Number(value))) {
preFillValue = Number(value)
}
} else {
preFillValue = value
}
}
}
return preFillValue
}
/** reset form if show_blank_form is true */
watch(submitted, (nextVal) => {
if (nextVal && sharedFormView.value?.show_blank_form) {

2
packages/nc-gui/composables/useViewData.ts

@ -297,7 +297,7 @@ export function useViewData(
fk_column_id: c.id,
fk_view_id: viewMeta.value?.id,
...(fieldById[c.id!] ? fieldById[c.id!] : {}),
meta: { ...parseProp(fieldById[c.id!]?.meta), ...parseProp(c.meta) }, // TODO: discuss with @pranav
meta: { ...parseProp(fieldById[c.id!]?.meta), ...parseProp(c.meta) },
order: (fieldById[c.id!] && fieldById[c.id!].order) || order++,
id: fieldById[c.id!] && fieldById[c.id!].id,
}))

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

@ -926,7 +926,15 @@
"startCommenting": "Start commenting!",
"clearForm": "Clear Form",
"addFieldFromFormView": "Add Field",
"selectAllFields": "Select all fields"
"selectAllFields": "Select all fields",
"preFilledFields": {
"title": "Enable Pre-fill",
"default": "Default",
"locked": "Lock pre-filled fields as read-only",
"hidden": "Hide pre-filled fields",
"lockedFieldTooltip": "Pre-filled value"
},
"getPreFilledLink": "Get Pre-filled Link"
},
"tooltip": {
"reachedSourceLimit": "Limited to only one data source for the moment",
@ -956,7 +964,9 @@
"clearMetadata": "Clear all metadata from meta tables.",
"clientKey": "Select .key file",
"clientCert": "Select .cert file",
"clientCA": "Select CA file"
"clientCA": "Select CA file",
"preFillFormInfo": "Generate share form URL with pre-filled field data. To get a pre-filled link, make sure you’ve filled the necessary fields in the form view builder.",
"surveyFormInfo": "Form mode with one field per page"
},
"placeholder": {
"selectSlackChannels": "Select Slack channels",

6
packages/nc-gui/lib/enums.ts

@ -136,3 +136,9 @@ export enum ImportSource {
URL = 'url',
STRING = 'string',
}
export enum PreFilledMode {
Default = 'default',
Hidden = 'hidden',
Locked = 'locked',
}

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

@ -2,7 +2,7 @@ import type { BaseType, ColumnType, FilterType, MetaType, PaginatedType, Roles,
import type { I18n } from 'vue-i18n'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { UploadFile } from 'ant-design-vue'
import type { ImportSource, ImportType, TabType } from './enums'
import type { ImportSource, ImportType, PreFilledMode, TabType } from './enums'
import type { rolePermissions } from './acl'
interface User {
@ -115,6 +115,8 @@ interface SharedViewMeta extends Record<string, any> {
theme?: Partial<ThemeConfig>
allowCSVDownload?: boolean
rtl?: boolean
preFillEnabled?: boolean
preFilledMode?: PreFilledMode
}
interface SharedView {

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

@ -87,6 +87,15 @@ p {
&:not(.layout-list) {
@apply rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500 overflow-hidden;
&.readonly {
@apply bg-gray-50 cursor-not-allowed;
input,
textarea {
@apply !bg-transparent;
}
}
& > div {
@apply !bg-transparent;
}
@ -107,11 +116,17 @@ p {
}
}
&:not(.readonly) {
input,
textarea,
&.nc-virtual-cell {
@apply bg-white !disabled:bg-transparent;
}
}
input,
textarea,
&.nc-virtual-cell {
@apply bg-white dark:(bg-slate-500 text-white);
.ant-btn {
@apply dark:(bg-slate-300);
}
@ -120,9 +135,7 @@ p {
@apply dark:(bg-slate-700 text-white);
}
}
&:not(.layout-list) > div {
@apply bg-white dark:(bg-slate-500 text-white);
}
&.layout-list > div {
.ant-btn {
@apply dark:(bg-slate-300);
@ -138,6 +151,9 @@ p {
& > div {
@apply w-full;
}
&.readonly > div {
@apply px-3 py-1;
}
textarea {
@apply px-3;

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

@ -180,39 +180,44 @@ const onDecode = async (scannedCodeValue: string) => {
</div>
<div>
<LazySmartsheetDivDataCell class="flex relative">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="null"
class="mt-0 nc-input nc-cell"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
/>
<NcTooltip :disabled="!field?.read_only">
<template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template>
<LazySmartsheetDivDataCell class="flex relative">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="null"
class="mt-0 nc-input nc-cell"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:read-only="field?.read_only"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input truncate"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="[
`nc-form-input-${field.title?.replaceAll(' ', '')}`,
{ 'layout-list': parseProp(field?.meta)?.isList },
]"
:column="field"
edit-enabled
/>
<a-button
v-if="field.enable_scanner"
class="nc-btn-fill-form-column-by-scan nc-toolbar-btn"
:alt="$t('activity.fillByCodeScan')"
@click="showCodeScannerForFieldTitle(field.title)"
>
<div class="flex items-center gap-1">
<component :is="iconMap.qrCodeScan" class="h-5 w-5" />
</div>
</a-button>
</LazySmartsheetDivDataCell>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input truncate"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="[
`nc-form-input-${field.title?.replaceAll(' ', '')}`,
{ 'layout-list': parseProp(field?.meta)?.isList, 'readonly': field?.read_only },
]"
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
/>
<a-button
v-if="field.enable_scanner"
class="nc-btn-fill-form-column-by-scan nc-toolbar-btn"
:alt="$t('activity.fillByCodeScan')"
@click="showCodeScannerForFieldTitle(field.title)"
>
<div class="flex items-center gap-1">
<component :is="iconMap.qrCodeScan" class="h-5 w-5" />
</div>
</a-button>
</LazySmartsheetDivDataCell>
</NcTooltip>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-sm mt-2">
<template v-if="isVirtualCol(field)">

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

@ -280,37 +280,45 @@ onMounted(() => {
<LazyCellRichText :value="field?.description" class="!h-auto -ml-1" is-form-field read-only sync-value-change />
</div>
<LazySmartsheetDivDataCell v-if="field.title" class="relative nc-form-data-cell">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
v-model="formState[field.title]"
class="mt-0 nc-input h-auto"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input h-auto"
:class="parseProp(field?.meta)?.isList ? 'layout-list' : ''"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
edit-enabled
/>
<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 }}
<NcTooltip :disabled="!field?.read_only">
<template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template>
<LazySmartsheetDivDataCell v-if="field.title" class="relative nc-form-data-cell">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
v-model="formState[field.title]"
class="mt-0 nc-input h-auto"
:class="{
readonly: field?.read_only,
}"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:read-only="field?.read_only"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input h-auto"
:class="{ 'layout-list': parseProp(field?.meta)?.isList, 'readonly': field?.read_only }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
/>
<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 v-if="field.uidt === UITypes.LongText" class="text-sm text-gray-500 flex flex-wrap items-center">
{{ $t('general.shift') }} <MdiAppleKeyboardShift class="mx-1 text-primary" /> + {{ $t('general.enter') }}
<MaterialSymbolsKeyboardReturn class="mx-1 text-primary" /> {{ $t('msg.makeLineBreak') }}
</div>
</div>
<div v-if="field.uidt === UITypes.LongText" class="text-sm text-gray-500 flex flex-wrap items-center">
{{ $t('general.shift') }} <MdiAppleKeyboardShift class="mx-1 text-primary" /> + {{ $t('general.enter') }}
<MaterialSymbolsKeyboardReturn class="mx-1 text-primary" /> {{ $t('msg.makeLineBreak') }}
</div>
</div>
</LazySmartsheetDivDataCell>
</LazySmartsheetDivDataCell>
</NcTooltip>
</div>
<div class="ml-1 mt-4 flex w-full text-lg">

3
packages/nc-gui/store/views.ts

@ -116,6 +116,8 @@ export const useViewsStore = defineStore('viewsStore', () => {
// Used for Grid View Pagination
const isPaginationLoading = ref(true)
const preFillFormSearchParams = ref('')
const loadViews = async ({
tableId,
ignoreLoading,
@ -322,6 +324,7 @@ export const useViewsStore = defineStore('viewsStore', () => {
activeSorts,
activeNestedFilters,
isActiveViewLocked,
preFillFormSearchParams,
}
})

11
packages/nc-gui/utils/cell.ts

@ -82,3 +82,14 @@ export const renderValue = (result?: any) => {
return dayjs(d).isValid() ? dayjs(d).format('YYYY-MM-DD HH:mm') : d
})
}
export const isNumericFieldType = (column: ColumnType, abstractType: any) => {
return (
isInt(column, abstractType) ||
isFloat(column, abstractType) ||
isDecimal(column) ||
isCurrency(column) ||
isPercent(column) ||
isDuration(column)
)
}

6
packages/nc-gui/utils/dataUtils.ts

@ -1,4 +1,4 @@
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { RelationTypes, UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { isColumnRequiredAndNull } from './columnUtils'
import type { Row } from '~/lib'
@ -102,9 +102,9 @@ export const rowDefaultData = (columns: ColumnType[] = []) => {
if (
!isSystemColumn(col) &&
!isVirtualCol(col) &&
!isLinksOrLTAR({ uidt: col.uidt! }) &&
![UITypes.Rollup, UITypes.Lookup, UITypes.Formula, UITypes.Barcode, UITypes.QrCode].includes(col.uidt) &&
col?.cdf
col?.cdf &&
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(col.cdf)
) {
const defaultValue = col.cdf
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'/, '').replace(/'$/, '') : defaultValue

Loading…
Cancel
Save