Browse Source

Nc feat: form view conditional fields (#9433)

* chore(nocodb): add fk_parent_column_id in filter schema

* feat(nocodb): form view field level filter support

* fix(nc-gui): add migration for `fk_parent_column_id` filter property

* fix: add support to fetch all view filters

* fix(nc-gui): filter castType issue

* fix(nc-gui): form field title autofocus issue

* fix(nc-gui): small changes

* fix(nc-gui): update local form view filter on updating filter

* fix(nc-gui): add validate field visibility function

* fix(nc-gui): toggle eye icon based on field conditional visibility

* fix(nc-gui): show tooltip on hover form field visibility icon

* fix(nc-gui): show unique errors

* fix(nc-gui): sort form view field issue

* fix(nc-gui): add error handleling in form conditional field

* fix(nc-gui): validate field on reorder

* fix(nc-gui): disable add new filter if form field is first

* fix(nc-gui): disable undo redo filters in form view

* fix(nc-gui): move form filter class to ee

* fix(nc-gui): prevent unwanted api call on form field select

* fix(nc-gui): remove unwanted console

* feat(nc-gui): shared form view conditional fields

* fix(nc-gui): form filter cache issue

* fix(nc-gui): delete form filters by fk_parent_col_id

* fix(nc-gui): form view duplicate filters

* fix(nc-gui): column meta copy issue while duplicating form view

* fix(nc-gui): review changes

* docs: show on conditions

* fix(nc-gui): remove merge conflict code part

* fix(nc-gui): show first validation error in visible form columns on hover over config error

* fix(nc-gui): form view filter validate link field issue

* fix(nc-gui): duplicate form column filters on duplicating table

* fix(nc-gui): rename form field filters label to conditions

* fix(nc-gui): minor changes

* chore(nc-gui): lint

* fix(nocodb): migration conflict issue

* fix(nc-gui): currency field ui issue in filter input

* fix(nc-gui): rating field overflow issue in filter menu

* fix(nc-gui): form conditional field oss visibility issue

* test(nc-gui): form conditional field test

* fix(nc-gui): typo error

* chore(nc-gui): lint

* fix(nc-gui): filter input width issue

* fix: pw test fail issue

* fix(nc-gui): update pw test

* fix(nc-gui): show field field config error in form field list

* fix(nc-gui): grayed out form field list icon color

* fix(nc-gui): give precedence to hidden pre-filled fields over conditional fields

* fix(nocodb): use string type instead of any

* fix(nocodb): typo mistake

* fix(nocodb): use stringifyMetaProp instead of JSON.stringify

* fix(nc-gui): remove lazy loading from child components of form field settings

* fix(nc-gui): increase gap between plus & delete btn from group filter menu

* fix(nc-gui): max callstack issue after adding group filter from form view

* fix(nc-gui): increase min width of filter dropdown in form view

* chore(nc-gui): lint

* fix(nc-gui): required virtual field validation issue in shared form

* fix(nc-gui): delete conditionally hidden field data while submiting form

* fix(nc-gui): handle bt or oo cell conditional field validation issue

* chore(nc-gui): lint

* fix(nc-gui): new is utils file function name conflicts

* fix(nc-gui): remove console

---------

Co-authored-by: Raju Udava <86527202+dstala@users.noreply.github.com>
pull/9502/head
Ramesh Mane 2 months ago committed by GitHub
parent
commit
93734e5b03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/nc-gui/components/cell/Currency.vue
  2. 128
      packages/nc-gui/components/smartsheet/Form.vue
  3. 10
      packages/nc-gui/components/smartsheet/form/field-config-error.vue
  4. 6
      packages/nc-gui/components/smartsheet/form/field-settings.vue
  5. 48
      packages/nc-gui/components/smartsheet/form/field-settings/visibility.vue
  6. 458
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  7. 10
      packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue
  8. 3
      packages/nc-gui/composables/useApi/interceptors.ts
  9. 25
      packages/nc-gui/composables/useFormViewStore.ts
  10. 110
      packages/nc-gui/composables/useSharedFormViewStore.ts
  11. 2
      packages/nc-gui/composables/useSmartsheetStore.ts
  12. 10
      packages/nc-gui/composables/useViewData.ts
  13. 20
      packages/nc-gui/composables/useViewFilters.ts
  14. 5
      packages/nc-gui/context/index.ts
  15. 10
      packages/nc-gui/lang/en.json
  16. 47
      packages/nc-gui/lib/form.ts
  17. 8
      packages/nc-gui/utils/dataUtils.ts
  18. 178
      packages/nc-gui/utils/is.ts
  19. 19
      packages/noco-docs/docs/090.views/040.view-types/030.form.md
  20. BIN
      packages/noco-docs/static/img/v2/views/form-view/show-on-conditions.png
  21. 3
      packages/nocodb/src/controllers/filters.controller.ts
  22. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  23. 16
      packages/nocodb/src/meta/migrations/v2/nc_063_form_field_filter.ts
  24. 4
      packages/nocodb/src/models/Column.ts
  25. 183
      packages/nocodb/src/models/Filter.ts
  26. 2
      packages/nocodb/src/models/HookFilter.ts
  27. 7
      packages/nocodb/src/models/View.ts
  28. 2
      packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts
  29. 3
      packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts
  30. 1
      packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts
  31. 17
      packages/nocodb/src/schema/swagger-v2.json
  32. 17
      packages/nocodb/src/schema/swagger.json
  33. 12
      packages/nocodb/src/services/filters.service.ts
  34. 60
      tests/playwright/pages/Dashboard/Form/formConditionalFields.ts
  35. 14
      tests/playwright/pages/Dashboard/Form/index.ts
  36. 14
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  37. 10
      tests/playwright/pages/SharedForm/index.ts
  38. 255
      tests/playwright/tests/db/views/viewForm.spec.ts

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

@ -107,7 +107,7 @@ onMounted(() => {
v-model="vModel"
type="number"
class="nc-cell-field h-full border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
:class="isForm && !isEditColumn ? 'flex flex-1' : 'w-full'"
:class="isForm && !isEditColumn && !hidePrefix ? 'flex flex-1' : 'w-full'"
:placeholder="placeholder"
:disabled="readOnly"
@blur="onBlur"

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

@ -93,15 +93,24 @@ const {
clearValidate,
fieldMappings,
isValidRedirectUrl,
loadAllviewFilters,
allViewFilters,
checkFieldVisibility,
} = useProvideFormViewStore(meta, view, formViewData, updateFormView, isEditable)
const { preFillFormSearchParams } = storeToRefs(useViewsStore())
const reloadEventHook = inject(ReloadViewDataHookInj, createEventHook())
reloadEventHook.on(async () => {
await Promise.all([loadFormView(), loadReleatedMetas()])
setFormData()
reloadEventHook.on(async (params) => {
if (params?.isFormFieldFilters) {
setTimeout(() => {
checkFieldVisibility()
}, 100)
} else {
await Promise.all([loadFormView(), loadReleatedMetas()])
setFormData()
}
})
const { fields, showAll, hideAll } = useViewColumnsOrThrow()
@ -303,10 +312,16 @@ const updatePreFillFormSearchParams = useDebounceFn(() => {
async function submitForm() {
if (isLocked.value || !isUIAllowed('dataInsert')) return
for (const col of visibleColumns.value) {
if (isRequired(col, col.required) && formState.value[col.title] === undefined) {
for (const col of localColumns.value) {
if (col.show && col.title && isRequired(col, col.required) && formState.value[col.title] === undefined) {
formState.value[col.title] = null
}
// handle filter out conditionally hidden field data
if ((!col.visible || !col.show) && col.title) {
delete formState.value[col.title]
delete state.value[col.title]
}
}
try {
@ -417,6 +432,8 @@ async function onMove(event: any, isVisibleFormFields = false) {
return 0
})
checkFieldVisibility()
$e('a:form-view:reorder')
}
@ -520,6 +537,8 @@ function setFormData() {
.filter((f) => !hiddenColTypes.includes(f.uidt) && !systemFieldsIds.value.includes(f.fk_column_id))
.sort((a, b) => a.order - b.order)
.map((c) => ({ ...c, required: !!c.required }))
checkFieldVisibility()
}
async function updateEmail() {
@ -687,6 +706,13 @@ async function loadReleatedMetas() {
)
}
const updateActiveFieldDescription = (value) => {
if (!activeField.value || activeField.value?.description === value) return
activeField.value.description = value
updateColMeta(activeField.value)
}
onMounted(async () => {
if (imageCropperData.value.src) {
URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
@ -696,9 +722,10 @@ onMounted(async () => {
isLoadingFormView.value = true
await Promise.all([loadFormView(), loadReleatedMetas()])
await Promise.all([loadFormView(), loadReleatedMetas(), loadAllviewFilters()])
setFormData()
isLoadingFormView.value = false
})
@ -762,8 +789,8 @@ watch(activeField, (newValue, oldValue) => {
}
})
watch([focusLabel, activeField], () => {
if (activeField && focusLabel.value) {
watch(focusLabel, () => {
if (activeField.value && focusLabel.value) {
nextTick(() => {
focusLabel.value?.focus()
})
@ -1214,13 +1241,34 @@ useEventListener(
/>
</NcButton>
</div>
<div class="text-sm font-semibold text-gray-800">
<span data-testid="nc-form-input-label">
{{ element.label || element.title }}
</span>
<span v-if="isRequired(element, element.required)" class="text-red-500 text-base leading-[18px]"
>&nbsp;*</span
<div class="flex items-center gap-3">
<NcTooltip
v-if="allViewFilters[element.fk_column_id]?.length"
class="relative h-3.5 w-3.5 flex cursor-pointer"
placement="topLeft"
>
<template #title> Conditionally visible field </template>
<Transition name="icon-fade">
<GeneralIcon
v-if="element?.visible"
icon="eye"
class="nc-field-visibility-icon nc-field-visible w-3.5 h-3.5 flex-none text-nc-content-gray-muted"
/>
<GeneralIcon
v-else
icon="eyeSlash"
class="nc-field-visibility-icon w-3.5 h-3.5 flex-none text-nc-content-gray-muted"
/>
</Transition>
</NcTooltip>
<div class="text-sm font-semibold text-gray-800">
<span data-testid="nc-form-input-label">
{{ element.label || element.title }}
</span>
<span v-if="isRequired(element, element.required)" class="text-red-500 text-base leading-[18px]"
>&nbsp;*</span
>
</div>
</div>
<LazyCellRichText
@ -1269,7 +1317,9 @@ useEventListener(
</LazySmartsheetDivDataCell>
</a-form-item>
<LazySmartsheetFormFieldConfigError :column="element" />
<div>
<LazySmartsheetFormFieldConfigError :column="element" mode="preview" />
</div>
</div>
</div>
</div>
@ -1423,13 +1473,13 @@ useEventListener(
/>
<LazyCellRichText
v-model:value="activeField.description"
:value="activeField.description"
:placeholder="$t('msg.info.formHelpText')"
class="form-meta-input nc-form-input-help-text"
is-form-field
:hidden-bubble-menu-options="hiddenBubbleMenuOptions"
data-testid="nc-form-input-help-text"
@update:value="updateColMeta(activeField)"
@update:value="updateActiveFieldDescription"
/>
</div>
<LazySmartsheetFormFieldSettings v-if="activeField"></LazySmartsheetFormFieldSettings>
@ -1559,8 +1609,12 @@ useEventListener(
class="flex-1 flex items-center cursor-pointer max-w-[calc(100%_-_40px)]"
@click.prevent="onFormItemClick(field, true)"
>
<SmartsheetHeaderVirtualCellIcon v-if="field && isVirtualCol(field)" :column-meta="field" />
<SmartsheetHeaderCellIcon v-else :column-meta="field" />
<SmartsheetHeaderVirtualCellIcon
v-if="field && isVirtualCol(field)"
:column-meta="field"
class="!text-gray-600"
/>
<SmartsheetHeaderCellIcon v-else :column-meta="field" class="!text-gray-600" />
<div class="flex-1 flex items-center justify-start max-w-[calc(100%_-_28px)]">
<div class="w-full flex items-center">
<div class="ml-1 inline-flex" :class="field.label?.trim() ? 'max-w-1/2' : 'max-w-[95%]'">
@ -1588,9 +1642,13 @@ useEventListener(
</NcTooltip>
<span>)</span>
</div>
<span v-if="isRequired(field, field.required)" class="text-red-500 text-sm align-top"
>&nbsp;*</span
>
<span v-if="isRequired(field, field.required)" class="text-red-500 text-sm align-top">
&nbsp;*
</span>
<div class="flex items-center">
<LazySmartsheetFormFieldConfigError :column="field" mode="list" />
</div>
</div>
</div>
</div>
@ -2009,6 +2067,32 @@ useEventListener(
}
}
}
.icon-fade-enter-active,
.icon-fade-leave-active {
transition: opacity 0.5s ease, transform 0.5s ease; /* Added scaling transition */
position: absolute;
}
.icon-fade-enter-from {
opacity: 0;
transform: scale(0.5); /* Start smaller and scale up */
}
.icon-fade-enter-to {
opacity: 1;
transform: scale(1); /* Scale to full size */
}
.icon-fade-leave-from {
opacity: 1;
transform: scale(1); /* Start at full size */
}
.icon-fade-leave-to {
opacity: 0;
transform: scale(0.5); /* Scale down and fade out */
}
</style>
<style lang="scss">

10
packages/nc-gui/components/smartsheet/form/field-config-error.vue

@ -1,3 +1,11 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { type ColumnType } from 'nocodb-sdk'
interface Props {
column: ColumnType
mode: 'preview' | 'list'
}
defineProps<Props>()
</script>
<template><div></div></template>

6
packages/nc-gui/components/smartsheet/form/field-settings.vue

@ -60,13 +60,15 @@ const columnSupportsScanning = (elementType: UITypes) =>
</div>
</div>
<SmartsheetFormFieldSettingsVisibility />
<!-- Limit options -->
<div v-if="isSelectTypeCol(activeField.uidt)" class="w-full flex items-start justify-between gap-3">
<div class="flex-1 max-w-[calc(100%_-_40px)]">
<div class="font-medium text-gray-800">{{ $t('labels.limitOptions') }}</div>
<div class="text-gray-500 mt-1">{{ $t('labels.limitOptionsSubtext') }}.</div>
<div v-if="activeField.meta.isLimitOption" class="mt-3">
<LazySmartsheetFormLimitOptions
<SmartsheetFormLimitOptions
v-model:model-value="activeField.meta.limitOptions"
:form-field-state="formState[activeField.title] || ''"
:column="activeField"
@ -75,7 +77,7 @@ const columnSupportsScanning = (elementType: UITypes) =>
@update:form-field-state="(value)=>{
formState[activeField!.title] = value
}"
></LazySmartsheetFormLimitOptions>
></SmartsheetFormLimitOptions>
</div>
</div>

48
packages/nc-gui/components/smartsheet/form/field-settings/visibility.vue

@ -0,0 +1,48 @@
<script lang="ts" setup>
const { activeField } = useFormViewStoreOrThrow()
const isOpen = ref<boolean>(false)
</script>
<template>
<div v-if="activeField" class="flex flex-col">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="text-gray-800 font-medium">{{ $t('labels.showOnConditions') }}</div>
<div class="flex flex-col">
<NcTooltip placement="left">
<template #title>
<div class="text-center">
{{ $t('msg.info.thisFeatureIsOnlyAvailableInEnterpriseEdition') }}
</div>
</template>
<div
class="nc-form-field-visibility-btn border-1 rounded-lg py-1 px-3 flex items-center justify-between gap-2 !min-w-[170px] transition-all select-none text-sm opacity-50 cursor-not-allowed"
:class="{
'!border-brand-500': isOpen,
'border-gray-200': !isOpen,
}"
>
<div class="nc-form-field-visibility-conditions-count flex-1">No conditions</div>
<GeneralIcon icon="settings" class="flex-none w-4 h-4" />
</div>
</NcTooltip>
</div>
</div>
<div>
<div class="text-sm text-gray-500">{{ $t('labels.showFieldOnConditionsMet') }}</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped></style>
<style lang="scss">
.nc-form-field-visibility-dropdown {
@apply rounded-2xl border-gray-200;
box-shadow: 0px 20px 24px -4px rgba(0, 0, 0, 0.1), 0px 8px 8px -4px rgba(0, 0, 0, 0.04);
}
</style>

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

@ -15,7 +15,12 @@ interface Props {
isOpen?: boolean
rootMeta?: any
linkColId?: string
parentColId?: string
actionBtnType?: 'text' | 'secondary'
/** Custom filter function */
filterOption?: (column: ColumnType) => boolean
visibilityError?: Record<string, string>
disableAddNewFilter?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -27,7 +32,10 @@ const props = withDefaults(defineProps<Props>(), {
webHook: false,
link: false,
linkColId: undefined,
parentColId: undefined,
actionBtnType: 'text',
visibilityError: () => ({}),
disableAddNewFilter: false,
})
const emit = defineEmits(['update:filtersLength', 'update:draftFilter', 'update:modelValue'])
@ -40,7 +48,19 @@ const draftFilter = useVModel(props, 'draftFilter', emit)
const modelValue = useVModel(props, 'modelValue', emit)
const { nestedLevel, parentId, autoSave, hookId, showLoading, webHook, link, linkColId } = toRefs(props)
const {
nestedLevel,
parentId,
autoSave,
hookId,
showLoading,
webHook,
link,
linkColId,
parentColId,
visibilityError,
disableAddNewFilter,
} = toRefs(props)
const nested = computed(() => nestedLevel.value > 0)
@ -63,7 +83,7 @@ const isPublic = inject(IsPublicInj, ref(false))
const { $e } = useNuxtApp()
const { nestedFilters } = useSmartsheetStoreOrThrow()
const { nestedFilters, isForm } = useSmartsheetStoreOrThrow()
const currentFilters = modelValue.value || (!link.value && !webHook.value && nestedFilters.value) || []
@ -72,7 +92,10 @@ const columns = computed(() => meta.value?.columns)
const fieldsToFilter = computed(() =>
(columns.value || []).filter((c) => {
if (link.value && isSystemColumn(c) && !c.pk && !isCreatedOrLastModifiedTimeCol(c)) return false
return !excludedFilterColUidt.includes(c.uidt as UITypes)
const customFilter = props.filterOption ? props.filterOption(c) : true
return !excludedFilterColUidt.includes(c.uidt as UITypes) && customFilter
}),
)
@ -96,7 +119,11 @@ const {
parentId,
computed(() => autoSave.value),
() => {
reloadDataHook.trigger({ shouldShowLoading: showLoading.value, offset: 0 })
reloadDataHook.trigger({
shouldShowLoading: showLoading.value,
offset: 0,
isFormFieldFilters: isForm.value && !webHook.value,
})
reloadAggregate?.trigger()
},
currentFilters,
@ -105,6 +132,7 @@ const {
link.value,
linkColId,
fieldsToFilter,
parentColId,
)
const { getPlanLimit } = useWorkspace()
@ -247,7 +275,7 @@ const applyChanges = async (hookOrColId?: string, nested = false, isConditionSup
for (const nestedFilter of localNestedFilters.value) {
if (nestedFilter.parentId) {
await nestedFilter.applyChanges(hookOrColId, true)
await nestedFilter.applyChanges(hookOrColId, true, undefined)
}
}
}
@ -525,56 +553,59 @@ const changeToDynamic = async (filter, i) => {
data-testid="nc-filter"
class="menu-filter-dropdown w-min"
:class="{
'max-h-[max(80vh,500px)] min-w-112 py-2 pl-4': !nested,
'max-h-[max(80vh,500px)] min-w-122 py-2 pl-4': !nested,
'!min-w-127.5': isForm && !webHook,
'!min-w-full !w-full !pl-0': !nested && webHook,
'min-w-full': nested,
}"
>
<div v-if="nested" class="flex min-w-full w-min items-center mb-2">
<div v-if="nested" class="flex min-w-full w-min items-center gap-1 mb-2">
<div :class="[`nc-filter-logical-op-level-${nestedLevel}`]">
<slot name="start"></slot>
</div>
<div class="flex-grow"></div>
<NcDropdown :trigger="['hover']" overlay-class-name="nc-dropdown-filter-group-sub-menu">
<GeneralIcon icon="plus" class="cursor-pointer" />
<NcDropdown :trigger="['hover']" overlay-class-name="nc-dropdown-filter-group-sub-menu" :disabled="disableAddNewFilter">
<NcButton size="xs" type="text" :disabled="disableAddNewFilter">
<GeneralIcon icon="plus" class="cursor-pointer" />
</NcButton>
<template #overlay>
<NcMenu>
<template v-if="isEeUI && !isPublic">
<template v-if="!isEeUI && !isPublic">
<template v-if="filtersCount < getPlanLimit(PlanLimitTypes.FILTER_LIMIT)">
<NcMenuItem @click.stop="addFilter()">
<NcMenuItem data-testid="add-filter-menu" @click.stop="addFilter">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" />
<!-- Add Filter -->
{{ $t('activity.addFilter') }}
{{ isForm && !webHook ? $t('activity.addCondition') : $t('activity.addFilter') }}
</div>
</NcMenuItem>
<NcMenuItem v-if="nestedLevel < 5" @click.stop="addFilterGroup()">
<NcMenuItem v-if="nestedLevel < 5" data-testid="add-filter-group-menu" @click.stop="addFilterGroup">
<div class="flex items-center gap-1">
<!-- Add Filter Group -->
<component :is="iconMap.plusSquare" />
{{ $t('activity.addFilterGroup') }}
{{ isForm && !webHook ? $t('activity.addConditionGroup') : $t('activity.addFilterGroup') }}
</div>
</NcMenuItem>
</template>
</template>
<template v-else>
<NcMenuItem @click.stop="addFilter()">
<NcMenuItem data-testid="add-filter-menu" @click.stop="addFilter">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" />
<!-- Add Filter -->
{{ $t('activity.addFilter') }}
{{ isForm && !webHook ? $t('activity.addCondition') : $t('activity.addFilter') }}
</div>
</NcMenuItem>
<NcButton v-if="!webHook && nestedLevel < 5" @click.stop="addFilterGroup()">
<NcMenuItem v-if="!webHook && nestedLevel < 5" data-testid="add-filter-group-menu" @click.stop="addFilterGroup">
<div class="flex items-center gap-1">
<!-- Add Filter Group -->
<component :is="iconMap.plusSquare" />
{{ $t('activity.addFilterGroup') }}
{{ isForm && !webHook ? $t('activity.addConditionGroup') : $t('activity.addFilterGroup') }}
</div>
</NcButton>
</NcMenuItem>
</template>
</NcMenu>
</template>
@ -608,6 +639,10 @@ const changeToDynamic = async (filter, i) => {
:show-loading="false"
:root-meta="rootMeta"
:link-col-id="linkColId"
:parent-col-id="parentColId"
:filter-option="filterOption"
:visibility-error="visibilityError"
:disable-add-new-filter="disableAddNewFilter"
>
<template #start>
<span v-if="!visibleFilters.indexOf(filter)" class="flex items-center nc-filter-where-label ml-1">{{
@ -695,190 +730,197 @@ const changeToDynamic = async (filter, i) => {
</a-select-option>
</NcSelect>
<SmartsheetToolbarFieldListAutoCompleteDropdown
:key="`${i}_6`"
v-model="filter.fk_column_id"
:class="{
'max-w-32': !webHook,
'!w-full': webHook,
}"
class="nc-filter-field-select min-w-32 max-h-8"
:columns="fieldsToFilter"
:disabled="filter.readOnly"
:meta="meta"
@click.stop
@change="selectFilterField(filter, i)"
/>
<NcSelect
v-model:value="filter.comparison_op"
v-e="['c:filter:comparison-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="caption nc-filter-operation-select !min-w-26.75 max-h-8"
:placeholder="$t('labels.operation')"
:class="{
'!max-w-26.75': !webHook,
'!w-full': webHook,
}"
density="compact"
variant="solo"
:disabled="filter.readOnly"
hide-details
dropdown-class-name="nc-dropdown-filter-comp-op !max-w-80"
@change="filterUpdateCondition(filter, i)"
<NcTooltip
v-if="isForm && !webHook && !fieldsToFilter.find((c) => c?.id === filter.fk_column_id)"
class="flex-1 flex items-center gap-2 px-2 !text-red-500 cursor-pointer"
:disabled="!filter.fk_column_id || !visibilityError[filter.fk_column_id]"
>
<template
v-for="compOp of comparisonOpList(types[filter.fk_column_id], getColumn(filter)?.meta?.date_format)"
:key="compOp.value"
<template #title> {{ visibilityError[filter.fk_column_id!] ?? '' }}</template>
<GeneralIcon icon="alertTriangle" class="flex-none" />
{{ $t('title.fieldInaccessible') }}
</NcTooltip>
<template v-else>
<SmartsheetToolbarFieldListAutoCompleteDropdown
:key="`${i}_6`"
v-model="filter.fk_column_id"
:class="{
'max-w-32': !webHook,
'!w-full': webHook,
}"
class="nc-filter-field-select min-w-32 max-h-8"
:columns="fieldsToFilter"
:disabled="filter.readOnly"
:meta="meta"
@click.stop
@change="selectFilterField(filter, i)"
/>
<NcSelect
v-model:value="filter.comparison_op"
v-e="['c:filter:comparison-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="caption nc-filter-operation-select !min-w-26.75 max-h-8"
:placeholder="$t('labels.operation')"
:class="{
'!max-w-26.75': !webHook,
'!w-full': webHook,
}"
density="compact"
variant="solo"
:disabled="filter.readOnly"
hide-details
dropdown-class-name="nc-dropdown-filter-comp-op !max-w-80"
@change="filterUpdateCondition(filter, i)"
>
<a-select-option v-if="isComparisonOpAllowed(filter, compOp)" :value="compOp.value">
<div class="flex items-center w-full justify-between w-full gap-2">
<div class="truncate flex-1">{{ compOp.text }}</div>
<component
:is="iconMap.check"
v-if="filter.comparison_op === compOp.value"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</template>
</NcSelect>
<template
v-for="compOp of comparisonOpList(types[filter.fk_column_id], getColumn(filter)?.meta?.date_format)"
:key="compOp.value"
>
<a-select-option v-if="isComparisonOpAllowed(filter, compOp)" :value="compOp.value">
<div class="flex items-center w-full justify-between w-full gap-2">
<div class="truncate flex-1">{{ compOp.text }}</div>
<component
:is="iconMap.check"
v-if="filter.comparison_op === compOp.value"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</template>
</NcSelect>
<div v-if="['blank', 'notblank'].includes(filter.comparison_op)" class="flex flex-grow"></div>
<NcSelect
v-else-if="isDateType(types[filter.fk_column_id])"
v-model:value="filter.comparison_sub_op"
v-e="['c:filter:sub-comparison-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="caption nc-filter-sub_operation-select min-w-28"
:class="{
'flex-grow w-full': !showFilterInput(filter),
'max-w-28': showFilterInput(filter) && !webHook,
}"
:placeholder="$t('labels.operationSub')"
density="compact"
variant="solo"
:disabled="filter.readOnly"
hide-details
dropdown-class-name="nc-dropdown-filter-comp-sub-op"
@change="filterUpdateCondition(filter, i)"
>
<template
v-for="compSubOp of comparisonSubOpList(filter.comparison_op, getColumn(filter)?.meta?.date_format)"
:key="compSubOp.value"
>
<a-select-option v-if="isComparisonSubOpAllowed(filter, compSubOp)" :value="compSubOp.value">
<div class="flex items-center w-full justify-between w-full gap-2 max-w-40">
<NcTooltip show-on-truncate-only class="truncate flex-1">
<template #title>{{ compSubOp.text }}</template>
{{ compSubOp.text }}
</NcTooltip>
<component
:is="iconMap.check"
v-if="filter.comparison_sub_op === compSubOp.value"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</template>
</NcSelect>
<div class="flex items-center flex-grow">
<div v-if="link && (filter.dynamic || filter.fk_value_col_id)" class="flex-grow">
<SmartsheetToolbarFieldListAutoCompleteDropdown
v-if="showFilterInput(filter)"
v-model="filter.fk_value_col_id"
class="nc-filter-field-select min-w-32 w-full max-h-8"
:columns="dynamicColumns(filter)"
:meta="rootMeta"
@change="saveOrUpdate(filter, i)"
/>
</div>
<div v-if="['blank', 'notblank'].includes(filter.comparison_op)" class="flex flex-grow"></div>
<template v-else>
<a-checkbox
v-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value"
dense
:disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)"
/>
<NcSelect
v-else-if="isDateType(types[filter.fk_column_id])"
v-model:value="filter.comparison_sub_op"
v-e="['c:filter:sub-comparison-op:select', { link: !!link, webHook: !!webHook }]"
:dropdown-match-select-width="false"
class="caption nc-filter-sub_operation-select min-w-28"
:class="{
'flex-grow w-full': !showFilterInput(filter),
'max-w-28': showFilterInput(filter) && !webHook,
}"
:placeholder="$t('labels.operationSub')"
density="compact"
variant="solo"
:disabled="filter.readOnly"
hide-details
dropdown-class-name="nc-dropdown-filter-comp-sub-op"
@change="filterUpdateCondition(filter, i)"
>
<template
v-for="compSubOp of comparisonSubOpList(filter.comparison_op, getColumn(filter)?.meta?.date_format)"
:key="compSubOp.value"
>
<a-select-option v-if="isComparisonSubOpAllowed(filter, compSubOp)" :value="compSubOp.value">
<div class="flex items-center w-full justify-between w-full gap-2 max-w-40">
<NcTooltip show-on-truncate-only class="truncate flex-1">
<template #title>{{ compSubOp.text }}</template>
{{ compSubOp.text }}
</NcTooltip>
<component
:is="iconMap.check"
v-if="filter.comparison_sub_op === compSubOp.value"
id="nc-selected-item-icon"
class="text-primary w-4 h-4"
/>
</div>
</a-select-option>
</template>
</NcSelect>
<div class="flex items-center flex-grow">
<div v-if="link && (filter.dynamic || filter.fk_value_col_id)" class="flex-grow">
<SmartsheetToolbarFieldListAutoCompleteDropdown
v-if="showFilterInput(filter)"
v-model="filter.fk_value_col_id"
class="nc-filter-field-select min-w-32 w-full max-h-8"
:columns="dynamicColumns(filter)"
:meta="rootMeta"
@change="saveOrUpdate(filter, i)"
/>
</div>
<SmartsheetToolbarFilterInput
v-if="showFilterInput(filter)"
class="nc-filter-value-select rounded-md min-w-34"
:class="{
'!w-full': webHook,
}"
:column="{ ...getColumn(filter), uidt: types[filter.fk_column_id] }"
:filter="filter"
@update-filter-value="(value) => updateFilterValue(value, filter, i)"
@click.stop
/>
<template v-else>
<a-checkbox
v-if="filter.field && types[filter.field] === 'boolean'"
v-model:checked="filter.value"
dense
:disabled="filter.readOnly"
@change="saveOrUpdate(filter, i)"
/>
<SmartsheetToolbarFilterInput
v-if="showFilterInput(filter)"
class="nc-filter-value-select rounded-md min-w-34"
:class="{
'!w-full': webHook,
'!w-18': !webHook,
}"
:column="{ ...getColumn(filter), uidt: types[filter.fk_column_id] }"
:filter="filter"
@update-filter-value="(value) => updateFilterValue(value, filter, i)"
@click.stop
/>
<div v-else-if="!isDateType(types[filter.fk_column_id])" class="flex-grow"></div>
</template>
<template v-if="link">
<NcDropdown
class="nc-settings-dropdown h-full flex items-center min-w-0 rounded-lg"
:trigger="['click']"
placement="bottom"
>
<NcButton type="text" size="small">
<GeneralIcon icon="settings" />
</NcButton>
<template #overlay>
<div class="relative overflow-visible min-h-17 w-10">
<div
class="absolute -top-21 flex flex-col min-h-34.5 w-70 p-1.5 bg-white rounded-lg border-1 border-gray-200 justify-start overflow-hidden"
style="box-shadow: 0px 4px 6px -2px rgba(0, 0, 0, 0.06), 0px -12px 16px -4px rgba(0, 0, 0, 0.1)"
:class="{
'-left-32.5': !isAddNewRecordGridMode,
'-left-21.5': isAddNewRecordGridMode,
}"
>
<div v-else-if="!isDateType(types[filter.fk_column_id])" class="flex-grow"></div>
</template>
<template v-if="link">
<NcDropdown
class="nc-settings-dropdown h-full flex items-center min-w-0 rounded-lg"
:trigger="['click']"
placement="bottom"
>
<NcButton type="text" size="small">
<GeneralIcon icon="settings" />
</NcButton>
<template #overlay>
<div class="relative overflow-visible min-h-17 w-10">
<div
class="px-4 py-3 flex flex-col select-none gap-y-2 cursor-pointer rounded-md hover:bg-gray-100 text-gray-600 nc-new-record-with-grid group"
@click="resetDynamicField(filter, i)"
class="absolute -top-21 flex flex-col min-h-34.5 w-70 p-1.5 bg-white rounded-lg border-1 border-gray-200 justify-start overflow-hidden"
style="box-shadow: 0px 4px 6px -2px rgba(0, 0, 0, 0.06), 0px -12px 16px -4px rgba(0, 0, 0, 0.1)"
>
<div class="flex flex-row items-center justify-between w-full">
<div class="flex flex-row items-center justify-start gap-x-3">Static condition</div>
<GeneralIcon
v-if="!filter.dynamic && !filter.fk_value_col_id"
icon="check"
class="w-4 h-4 text-primary"
/>
<div
class="px-4 py-3 flex flex-col select-none gap-y-2 cursor-pointer rounded-md hover:bg-gray-100 text-gray-600 nc-new-record-with-grid group"
@click="resetDynamicField(filter, i)"
>
<div class="flex flex-row items-center justify-between w-full">
<div class="flex flex-row items-center justify-start gap-x-3">Static condition</div>
<GeneralIcon
v-if="!filter.dynamic && !filter.fk_value_col_id"
icon="check"
class="w-4 h-4 text-primary"
/>
</div>
<div class="flex flex-row text-xs text-gray-400">Filter based on static value</div>
</div>
<div class="flex flex-row text-xs text-gray-400">Filter based on static value</div>
</div>
<div
v-e="['c:filter:dynamic-filter']"
class="px-4 py-3 flex flex-col select-none gap-y-2 cursor-pointer rounded-md hover:bg-gray-100 text-gray-600 nc-new-record-with-form group"
:class="
isDynamicFilterAllowed(filter) && showFilterInput(filter) ? 'cursor-pointer' : 'cursor-not-allowed'
"
@click="changeToDynamic(filter, i)"
>
<div class="flex flex-row items-center justify-between w-full">
<div class="flex flex-row items-center justify-start gap-x-2.5">Dynamic condition</div>
<GeneralIcon
v-if="filter.dynamic || filter.fk_value_col_id"
icon="check"
class="w-4 h-4 text-primary"
/>
<div
v-e="['c:filter:dynamic-filter']"
class="px-4 py-3 flex flex-col select-none gap-y-2 cursor-pointer rounded-md hover:bg-gray-100 text-gray-600 nc-new-record-with-form group"
:class="
isDynamicFilterAllowed(filter) && showFilterInput(filter) ? 'cursor-pointer' : 'cursor-not-allowed'
"
@click="changeToDynamic(filter, i)"
>
<div class="flex flex-row items-center justify-between w-full">
<div class="flex flex-row items-center justify-start gap-x-2.5">Dynamic condition</div>
<GeneralIcon
v-if="filter.dynamic || filter.fk_value_col_id"
icon="check"
class="w-4 h-4 text-primary"
/>
</div>
<div class="flex flex-row text-xs text-gray-400">Filter based on dynamic value</div>
</div>
<div class="flex flex-row text-xs text-gray-400">Filter based on dynamic value</div>
</div>
</div>
</div>
</template>
</NcDropdown>
</template>
</div>
</template>
</NcDropdown>
</template>
</div>
</template>
<NcButton
v-if="!filter.readOnly"
v-e="['c:filter:delete', { link: !!link, webHook: !!webHook }]"
@ -903,19 +945,34 @@ const changeToDynamic = async (filter, i) => {
'mt-1 mb-2': filters.length,
}"
>
<NcButton size="small" :type="actionBtnType" class="nc-btn-focus" @click.stop="addFilter()">
<NcButton
size="small"
:type="actionBtnType"
:disabled="disableAddNewFilter"
class="nc-btn-focus"
data-testid="add-filter"
@click.stop="addFilter()"
>
<div class="flex items-center gap-1">
<component :is="iconMap.plus" />
<!-- Add Filter -->
{{ $t('activity.addFilter') }}
{{ isForm && !webHook ? $t('activity.addCondition') : $t('activity.addFilter') }}
</div>
</NcButton>
<NcButton v-if="nestedLevel < 5" class="nc-btn-focus" :type="actionBtnType" size="small" @click.stop="addFilterGroup()">
<NcButton
v-if="nestedLevel < 5"
class="nc-btn-focus"
:disabled="disableAddNewFilter"
:type="actionBtnType"
size="small"
data-testid="add-filter-group"
@click.stop="addFilterGroup()"
>
<div class="flex items-center gap-1">
<!-- Add Filter Group -->
<component :is="iconMap.plus" />
{{ $t('activity.addFilterGroup') }}
{{ isForm && !webHook ? $t('activity.addConditionGroup') : $t('activity.addFilterGroup') }}
</div>
</NcButton>
</div>
@ -929,11 +986,11 @@ const changeToDynamic = async (filter, i) => {
'mt-1 mb-2': filters.length,
}"
>
<NcButton class="nc-btn-focus" size="small" :type="actionBtnType" @click.stop="addFilter()">
<NcButton class="nc-btn-focus" size="small" :type="actionBtnType" data-testid="add-filter" @click.stop="addFilter()">
<div class="flex items-center gap-1">
<component :is="iconMap.plus" />
<!-- Add Filter -->
{{ $t('activity.addFilter') }}
{{ isForm && !webHook ? $t('activity.addCondition') : $t('activity.addFilter') }}
</div>
</NcButton>
@ -942,12 +999,13 @@ const changeToDynamic = async (filter, i) => {
class="nc-btn-focus"
:type="actionBtnType"
size="small"
data-testid="add-filter-group"
@click.stop="addFilterGroup()"
>
<div class="flex items-center gap-1">
<!-- Add Filter Group -->
<component :is="iconMap.plus" />
{{ $t('activity.addFilterGroup') }}
{{ isForm && !webHook ? $t('activity.addConditionGroup') : $t('activity.addFilterGroup') }}
</div>
</NcButton>
</div>
@ -961,7 +1019,7 @@ const changeToDynamic = async (filter, i) => {
'ml-0.5': !nested,
}"
>
{{ $t('title.noFiltersAdded') }}
{{ isForm && !webHook ? $t('title.noConditionsAdded') : $t('title.noFiltersAdded') }}
</div>
<slot />

10
packages/nc-gui/components/smartsheet/toolbar/FilterInput.vue

@ -164,6 +164,16 @@ const componentProps = computed(() => {
}
return {}
}
case 'isCurrency': {
return { hidePrefix: true }
}
case 'isRating': {
return {
style: {
minWidth: `${(column.value?.meta?.max || 5) * 19}px`,
},
}
}
default: {
return {}
}

3
packages/nc-gui/composables/useApi/interceptors.ts

@ -49,7 +49,8 @@ export function addAxiosInterceptors(api: Api<any>) {
},
// Handle Error
async (error) => {
let isSharedPage = route.value?.params?.typeOrId === 'base' || route.value?.params?.typeOrId === 'ERD' || route.value.meta.public;
const isSharedPage =
route.value?.params?.typeOrId === 'base' || route.value?.params?.typeOrId === 'ERD' || route.value.meta.public
// if cancel request then throw error
if (error.code === 'ERR_CANCELED') return Promise.reject(error)

25
packages/nc-gui/composables/useFormViewStore.ts

@ -1,6 +1,6 @@
import type { Ref } from 'vue'
import type { RuleObject } from 'ant-design-vue/es/form'
import type { ColumnType, FormType, TableType, ViewType } from 'nocodb-sdk'
import type { ColumnType, FilterType, FormType, TableType, ViewType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isLinksOrLTAR } from 'nocodb-sdk'
import type { ValidateInfo } from 'ant-design-vue/es/form/useForm'
@ -20,14 +20,25 @@ const [useProvideFormViewStore, useFormViewStore] = useInjectionState(
const formResetHook = createEventHook<void>()
const allViewFilters = ref<Record<string, FilterType[]>>({})
const formState = ref<Record<string, any>>({})
const activeRow = ref('')
const localColumns = ref<Record<string, any>[]>([])
const activeRow = ref('')
const localColumnsMapByFkColumnId = computed(() => {
return localColumns.value.reduce((acc, c) => {
acc[c.fk_column_id] = c
const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order))
return acc
}, {} as Record<string, ColumnType & Record<string, any>>)
})
const visibleColumns = computed(() =>
localColumns.value.filter((f) => f.show).sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)),
)
const activeField = computed(() => visibleColumns.value.find((c) => c.id === activeRow.value) || null)
const activeColumn = computed(() => {
@ -168,6 +179,10 @@ const [useProvideFormViewStore, useFormViewStore] = useInjectionState(
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
const loadAllviewFilters = async () => {}
function checkFieldVisibility() {}
return {
onReset: formResetHook.on,
formState,
@ -185,6 +200,10 @@ const [useProvideFormViewStore, useFormViewStore] = useInjectionState(
fieldMappings,
isValidRedirectUrl,
formViewData,
loadAllviewFilters,
allViewFilters,
localColumnsMapByFkColumnId,
checkFieldVisibility,
}
},
'form-view-store',

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

@ -2,6 +2,7 @@ import dayjs from 'dayjs'
import type {
BoolType,
ColumnType,
FilterType,
FormColumnType,
FormType,
LinkToAnotherRecordType,
@ -67,6 +68,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const preFilledDefaultValueformState = ref<Record<string, any>>({})
const allViewFilters = ref<Record<string, FilterType[]>>({})
const isValidRedirectUrl = computed(
() => typeof sharedFormView.value?.redirect_url === 'string' && !!sharedFormView.value?.redirect_url?.trim(),
)
@ -80,15 +83,45 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}),
)
const localColumns = computed<(ColumnType & Record<string, any>)[]>(() => {
return (columns.value || [])?.filter((c) => supportedFields(c))
})
const localColumnsMapByFkColumnId = computed(() => {
return localColumns.value.reduce((acc, c) => {
acc[c.fk_column_id] = c
return acc
}, {} as Record<string, ColumnType & Record<string, any>>)
})
const fieldVisibilityValidator = computed(() => {
return new FormFilters({
nestedGroupedFilters: allViewFilters.value,
formViewColumns: localColumns.value,
formViewColumnsMapByFkColumnId: localColumnsMapByFkColumnId.value,
formState: { ...(formState.value || {}), ...(additionalState.value || {}) },
isSharedForm: true,
isMysql: (_sourceId?: string) => {
return ['mysql', ClientType.MYSQL].includes(sharedView.value?.client || ClientType.MYSQL)
},
getMeta,
})
})
const formColumns = computed(
() =>
columns.value
?.filter((c) => c.show)
.filter(
(col) => !isSystemColumn(col) && col.uidt !== UITypes.SpecificDBType && (!isVirtualCol(col) || isLinksOrLTAR(col.uidt)),
) || [],
columns.value?.filter((col) => {
const isVisible = col.show
return isVisible && supportedFields(col)
}) || [],
)
function supportedFields(col: ColumnType) {
return !isSystemColumn(col) && col.uidt !== UITypes.SpecificDBType && (!isVirtualCol(col) || isLinksOrLTAR(col.uidt))
}
const loadSharedView = async () => {
passwordError.value = null
@ -105,6 +138,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
sharedFormView.value = viewMeta.view
meta.value = viewMeta.model
loadAllviewFilters(Array.isArray(viewMeta?.filter?.children) ? viewMeta?.filter?.children : [])
const fieldById = (viewMeta.columns || []).reduce(
(o: Record<string, any>, f: Record<string, any>) => ({
...o,
@ -114,8 +149,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
)
columns.value = (viewMeta.model?.columns || [])
.filter((c) => fieldById[c.id])
.map((c) => {
.filter((c: ColumnType) => fieldById[c.id])
.map((c: ColumnType) => {
if (
!isSystemColumn(c) &&
!isVirtualCol(c) &&
@ -145,10 +180,13 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
return {
...c,
order: fieldById[c.id].order || c.order,
visible: true,
meta: { ...parseProp(fieldById[c.id].meta), ...parseProp(c.meta) },
description: fieldById[c.id].description,
}
})
.sort((a: ColumnType, b: ColumnType) => (a.order ?? Infinity) - (b.order ?? Infinity))
const _sharedViewMeta = (viewMeta as any).meta
sharedViewMeta.value = isString(_sharedViewMeta) ? JSON.parse(_sharedViewMeta) : _sharedViewMeta
@ -175,6 +213,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}
await handlePreFillForm()
checkFieldVisibility()
} catch (e: any) {
const error = await extractSdkResponseErrorMsgv2(e)
@ -212,27 +252,35 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
{
validator: (_rule: RuleObject, value: any) => {
return new Promise((resolve, reject) => {
if (isRequired(column)) {
if (isRequired(column) && column.show) {
if (typeof value === 'string') {
value = value.trim()
}
if (column.uidt === UITypes.Rating && (!value || Number(value) < 1)) {
return reject(t('msg.error.fieldRequired'))
}
if (
(column.uidt === UITypes.Checkbox && !value) ||
(column.uidt !== UITypes.Checkbox && !requiredFieldValidatorFn(value))
) {
return reject(t('msg.error.fieldRequired'))
}
if (column.uidt === UITypes.Rating && (!value || Number(value) < 1)) {
return reject(t('msg.error.fieldRequired'))
}
}
return resolve()
})
},
},
{
validator: (_rule: RuleObject) => {
return new Promise((resolve) => {
checkFieldVisibility()
return resolve()
})
},
},
]
const additionalRules = extractFieldValidator(parseProp(column.meta).validators ?? [], column)
@ -264,25 +312,36 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
const { validate, validateInfos, clearValidate } = useForm(validationFieldState, validators)
const handleAddMissingRequiredFieldDefaultState = () => {
for (const col of formColumns.value) {
const handleAddMissingRequiredFieldDefaultState = async () => {
for (const col of localColumns.value) {
if (
col.title &&
col.show &&
col.visible &&
isRequired(col) &&
formState.value[col.title] === undefined &&
additionalState.value[col.title] === undefined
) {
if (isVirtualCol(col)) {
additionalState.value[col.title] = null
additionalState.value = {
...(additionalState.value || {}),
[col.title]: null,
}
} else {
formState.value[col.title] = null
}
}
// handle filter out conditionally hidden field data
if (!col.visible && col.title) {
delete formState.value[col.title]
delete additionalState.value[col.title]
}
}
}
const validateAllFields = async () => {
handleAddMissingRequiredFieldDefaultState()
await handleAddMissingRequiredFieldDefaultState()
try {
// filter `undefined` keys which is hidden prefilled fields
@ -362,6 +421,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}
clearValidate()
checkFieldVisibility()
}
async function handlePreFillForm() {
@ -403,6 +463,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
switch (sharedViewMeta.value.preFilledMode) {
case PreFilledMode.Hidden: {
c.show = false
c.meta = { ...parseProp(c.meta), preFilledHiddenField: true }
break
}
case PreFilledMode.Locked: {
@ -636,7 +697,6 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
clearInterval(intvl)
}
clearForm()
clearValidate()
}
})
@ -661,6 +721,20 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
return false
}
function loadAllviewFilters(formViewFilters: FilterType[]) {
if (!formViewFilters.length) return
const formFilter = new FormFilters({ data: formViewFilters })
const allFilters = formFilter.getNestedGroupedFilters()
allViewFilters.value = { ...allFilters }
}
async function checkFieldVisibility() {
await fieldVisibilityValidator.value.validateVisibility()
}
watch(password, (next, prev) => {
if (next !== prev && passwordError.value) passwordError.value = null
})
@ -720,6 +794,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
handleAddMissingRequiredFieldDefaultState,
fieldMappings,
isValidRedirectUrl,
loadAllviewFilters,
checkFieldVisibility,
}
}, 'shared-form-view-store')

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

@ -58,7 +58,7 @@ const [useProvideSmartsheetStore, useSmartsheetStore] = useInjectionState(
const sorts = ref<SortType[]>(unref(initialSorts) ?? [])
const nestedFilters = ref<FilterType[]>(unref(initialFilters) ?? [])
const allFilters = ref<Filter[]>([])
const allFilters = ref<FilterType[]>([])
watch(
sorts,

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

@ -304,9 +304,17 @@ export function useViewData(
fk_column_id: c.id,
fk_view_id: viewMeta.value?.id,
...(fieldById[c.id!] ? fieldById[c.id!] : {}),
meta: { validators: [], ...parseProp(fieldById[c.id!]?.meta), ...parseProp(c.meta) },
meta: {
validators: [],
visibility: {
errors: {},
},
...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,
visible: true,
}))
.sort((a: Record<string, any>, b: Record<string, any>) => a.order - b.order) as Record<string, any>[]
} catch (e: any) {

20
packages/nc-gui/composables/useViewFilters.ts

@ -23,6 +23,7 @@ export function useViewFilters(
isLink?: boolean,
linkColId?: Ref<string>,
fieldsToFilter?: Ref<ColumnType[]>,
parentColId?: Ref<string>,
) {
const savingStatus: Record<number, boolean> = {}
@ -34,7 +35,7 @@ export function useViewFilters(
const reloadHook = inject(ReloadViewDataHookInj)
const { nestedFilters, allFilters } = useSmartsheetStoreOrThrow()
const { nestedFilters, allFilters, isForm } = useSmartsheetStoreOrThrow()
const { baseMeta } = storeToRefs(useBase())
@ -54,10 +55,13 @@ export function useViewFilters(
const filters = computed<ColumnFilterType[]>({
get: () => {
return nestedMode.value && !isLink && !isWebhook ? currentFilters.value! : _filters.value
return (nestedMode.value && !isLink && !isWebhook) || (isForm.value && !isWebhook) ? currentFilters.value! : _filters.value
},
set: (value: ColumnFilterType[]) => {
if (nestedMode.value) {
if (isForm.value && !isWebhook) {
currentFilters.value = value
return
} else if (nestedMode.value) {
currentFilters.value = value
if (!isLink && !isWebhook) {
if (!isNestedRoot) {
@ -204,6 +208,7 @@ export function useViewFilters(
fieldsToFilter?.value?.filter((col) => {
return !isSystemColumn(col)
})?.[0]?.id ?? undefined,
...(parentColId?.value ? { fk_parent_column_id: parentColId.value } : {}),
}
}
@ -214,6 +219,7 @@ export function useViewFilters(
is_group: true,
status: 'create',
logical_op: logicalOps.size === 1 ? logicalOps.values().next().value : 'and',
...(parentColId?.value ? { fk_parent_column_id: parentColId.value, children: [] } : {}),
}
}
@ -258,7 +264,7 @@ export function useViewFilters(
} = {}) => {
if (!view.value?.id) return
if (nestedMode.value) {
if (nestedMode.value || (isForm.value && !isWebhook)) {
// ignore restoring if not root filter group
return
}
@ -363,7 +369,7 @@ export function useViewFilters(
if (!view.value && !linkColId?.value) return
if (!undo) {
if (!undo && !(isForm.value && !isWebhook)) {
const lastFilter = lastFilters.value[i]
if (lastFilter) {
const delta = clone(getFieldDelta(filter, lastFilter))
@ -473,7 +479,7 @@ export function useViewFilters(
const deleteFilter = async (filter: ColumnFilterType, i: number, undo = false) => {
// update the filter status
filter.status = 'delete'
if (!undo && !filter.is_group) {
if (!undo && !filter.is_group && !(isForm.value && !isWebhook)) {
addUndo({
undo: {
fn: async (fl: ColumnFilterType) => {
@ -534,7 +540,7 @@ export function useViewFilters(
filters.value.push(
(draftFilter?.fk_column_id ? { ...placeholderFilter(), ...draftFilter } : placeholderFilter()) as ColumnFilterType,
)
if (!undo) {
if (!undo && !(isForm.value && !isWebhook)) {
addUndo({
undo: {
fn: async function undo(this: UndoRedoAction, i: number) {

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

@ -26,8 +26,9 @@ export const ReadonlyInj: InjectionKey<Ref<boolean>> = Symbol('readonly-injectio
export const RowHeightInj: InjectionKey<Ref<1 | 2 | 4 | 6 | undefined>> = Symbol('row-height-injection')
export const ScrollParentInj: InjectionKey<Ref<HTMLElement | undefined>> = Symbol('scroll-parent-injection')
/** when shouldShowLoading bool is passed, it indicates if a loading spinner should be visible while reloading */
export const ReloadViewDataHookInj: InjectionKey<EventHook<{ shouldShowLoading?: boolean; offset?: number } | void>> =
Symbol('reload-view-data-injection')
export const ReloadViewDataHookInj: InjectionKey<
EventHook<{ shouldShowLoading?: boolean; offset?: number; isFormFieldFilters?: boolean } | void>
> = Symbol('reload-view-data-injection')
export const ReloadViewMetaHookInj: InjectionKey<EventHook<boolean | void>> = Symbol('reload-view-meta-injection')
export const ReloadRowDataHookInj: InjectionKey<EventHook<{ shouldShowLoading?: boolean; offset?: number } | void>> =
Symbol('reload-row-data-injection')

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

@ -620,7 +620,9 @@
"fromScratch": "From scratch",
"fromFileAndExternalSources": "From files & external sources",
"directlyInRealTime": "Directly in real time",
"categories": "Categories"
"categories": "Categories",
"fieldInaccessible": "Field inaccessible",
"noConditionsAdded": "No conditions added"
},
"labels": {
"modifiedOn": "Modified on",
@ -985,7 +987,7 @@
"appearanceSettings": "Appearance settings",
"backgroundColor": "Background Color",
"hideNocodbBranding": "Hide NocoDB Branding",
"showOnConditions": "Show on condtions",
"showOnConditions": "Show on conditions",
"showFieldOnConditionsMet": "Shows field only when conditions are met",
"limitOptions": "Limit options",
"limitOptionsSubtext": "Limit options visible to users by selecting available options",
@ -1256,7 +1258,9 @@
},
"getPreFilledLink": "Get Pre-filled Link",
"group": "Group",
"goToDocs": "Go to Docs"
"goToDocs": "Go to Docs",
"addCondition": "Add condition",
"addConditionGroup": "Add condition group"
},
"tooltip": {
"currentDateNotAvail": "Current date option not available for the data source or the data type",

47
packages/nc-gui/lib/form.ts

@ -0,0 +1,47 @@
import type { ColumnType } from 'ant-design-vue/lib/table'
import { type FilterType } from 'nocodb-sdk'
type FormViewColumn = ColumnType & Record<string, any>
export class FormFilters {
allViewFilters: FilterType[]
protected groupedFilters: Record<string, FilterType[]>
nestedGroupedFilters: Record<string, FilterType[]>
formViewColumns: FormViewColumn[]
formViewColumnsMapByFkColumnId: Record<string, FormViewColumn>
formState: Record<string, any>
value: any
isMysql?: (sourceId?: string) => boolean
constructor({
data = [],
nestedGroupedFilters = {},
formViewColumns = [],
formViewColumnsMapByFkColumnId = {},
formState = {},
isMysql = undefined,
}: {
data?: FilterType[]
nestedGroupedFilters?: Record<string, FilterType[]>
formViewColumns?: FormViewColumn[]
formViewColumnsMapByFkColumnId?: Record<string, FormViewColumn>
formState?: Record<string, any>
isMysql?: (sourceId?: string) => boolean
} = {}) {
this.allViewFilters = data
this.groupedFilters = {}
this.nestedGroupedFilters = nestedGroupedFilters
this.formViewColumns = formViewColumns
this.formViewColumnsMapByFkColumnId = formViewColumnsMapByFkColumnId
this.formState = formState
this.isMysql = isMysql
}
setFilters(filters: FilterType[]) {
this.allViewFilters = filters
}
getNestedGroupedFilters() {}
validateVisibility() {}
}

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

@ -4,19 +4,19 @@ import { isColumnRequiredAndNull } from './columnUtils'
import type { Row } from '~/lib/types'
export const isValidValue = (val: unknown) => {
if (val === null || val === undefined) {
if (ncIsNull(val) || ncIsUndefined(val)) {
return false
}
if (typeof val === 'string' && val === '') {
if (ncIsString(val) && val === '') {
return false
}
if (Array.isArray(val) && val.length === 0) {
if (ncIsEmptyArray(val)) {
return false
}
if (typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 0) {
if (ncIsEmptyObject(val)) {
return false
}

178
packages/nc-gui/utils/is.ts

@ -0,0 +1,178 @@
/**
* Checks if a value is an object (excluding null).
*
* @param value - The value to check.
* @returns {boolean} - True if the value is an object, false otherwise.
*
* @example
* ```typescript
* const value = { key: 'value' };
* console.log(ncIsObject(value)); // true
* ```
*/
export function ncIsObject(value: any): boolean {
return value !== null && typeof value === 'object' && !ncIsArray(value)
}
/**
* Checks if a value is an empty object.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is an empty object, false otherwise.
*
* @example
* ```typescript
* const value = {};
* console.log(ncIsEmptyObject(value)); // true
* ```
*/
export function ncIsEmptyObject(value: any): boolean {
return ncIsObject(value) && Object.keys(value).length === 0
}
/**
* Checks if a value is an array.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is an array, false otherwise.
*
* @example
* ```typescript
* const value = [1, 2, 3];
* console.log(ncIsArray(value)); // true
* ```
*/
export function ncIsArray(value: any): boolean {
return Array.isArray(value)
}
/**
* Checks if a value is an empty array.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is an empty array, false otherwise.
*
* @example
* ```typescript
* const value = [];
* console.log(ncIsEmptyArray(value)); // true
*
* const nonEmptyArray = [1, 2, 3];
* console.log(ncIsEmptyArray(nonEmptyArray)); // false
* ```
*/
export function ncIsEmptyArray(value: any): boolean {
return ncIsArray(value) && value.length === 0
}
/**
* Checks if a value is a string.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is a string, false otherwise.
*
* @example
* ```typescript
* const value = 'Hello, world!';
* console.log(ncIsString(value)); // true
* ```
*/
export function ncIsString(value: any): boolean {
return typeof value === 'string'
}
/**
* Checks if a value is a number.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is a number, false otherwise.
*
* @example
* ```typescript
* const value = 42;
* console.log(ncIsNumber(value)); // true
* ```
*/
export function ncIsNumber(value: any): boolean {
return typeof value === 'number' && !isNaN(value)
}
/**
* Checks if a value is a boolean.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is a boolean, false otherwise.
*
* @example
* ```typescript
* const value = true;
* console.log(ncIsBoolean(value)); // true
* ```
*/
export function ncIsBoolean(value: any): boolean {
return typeof value === 'boolean'
}
/**
* Checks if a value is undefined.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is undefined, false otherwise.
*
* @example
* ```typescript
* const value = undefined;
* console.log(ncIsUndefined(value)); // true
* ```
*/
export function ncIsUndefined(value: any): boolean {
return typeof value === 'undefined'
}
/**
* Checks if a value is null.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is null, false otherwise.
*
* @example
* ```typescript
* const value = null;
* console.log(ncIsNull(value)); // true
* ```
*/
export function ncIsNull(value: any): boolean {
return value === null
}
/**
* Checks if a value is a function.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is a function, false otherwise.
*
* @example
* ```typescript
* const value = () => {};
* console.log(ncIsFunction(value)); // true
* ```
*/
export function ncIsFunction(value: any): boolean {
return typeof value === 'function'
}
/**
* Checks if a value is a promise.
*
* @param value - The value to check.
* @returns {boolean} - True if the value is a Promise, false otherwise.
*
* @example
* ```typescript
* const value = new Promise((resolve) => resolve(true));
* console.log(ncIsPromise(value)); // true
* ```
*/
export function ncIsPromise(value: any): boolean {
return value instanceof Promise
}

19
packages/noco-docs/docs/090.views/040.view-types/030.form.md

@ -233,9 +233,6 @@ Example
**Configuration Steps**
1. Click on the required field in the **Form Area**.
2. `Limit file types` Enter the permitted MIME types separated by a comma.
3. `Limit number of files` Enter the maximum number of files that can be uploaded.
1. Click on the required field in the **Form Area**.
2. In the `Limit file types` section, enter the permitted MIME types separated by a comma.
3. In the `Limit number of files` section, specify the maximum number of files that can be uploaded.
4. In the `Limit file size` section, specify the maximum allowable file size for the attachment.
@ -247,7 +244,21 @@ Check out this video for a more detailed explanation with examples:
<iframe width="560" height="315" src="https://www.youtube.com/embed/Kltbg_Dn23k?si=u1z5Hk9IuuX4jLcY&start=23" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen ></iframe>
</center>
## Pre-fill Form Fields
## Field "Show On Conditions"
Conditional fields in Form View allow you to control the visibility and behavior of chosen fields based on specific data entered in previous fields. You can dynamically hide or show form fields when certain filter conditions are met. This enables a tailored form experience, displaying only relevant fields based on user input.
Note that, for constructing the condition, you can only use fields that are present in the form & are defined prior to the field for which the condition is being set. This also limits reordering of the fields in the form - reordering of the fields will be allowed as long as the condition requirement above is not broken.
To enable conditional fields in Form View, Open form-view builder & follow the steps below:
1. Select the field for which you want to set the condition.
2. On the right side configuration panel, click on the Settings icon next to `Show On Condition` to open the conditional field settings.
3. Click on `Add Filter` OR `Add Filter Group` to add new condition(s) as required.
![Show On Condition](/img/v2/views/form-view/show-on-conditions.png)
NocoDB suffixes an eye icon next to the field name in the form builder to indicate that the field is conditionally visible.
## Pre-Filling Form Fields
NocoDB offers a convenient feature that allows pre-filling form fields with specific values by setting URL parameters. This functionality enables the creation of custom URLs with desired field values, streamlining data entry and enhancing user experience.
To construct a pre-filled form URL manually, ensure that the URL parameters are appropriately encoded in the following format: `?key1=value1&key2=value2`.

BIN
packages/noco-docs/static/img/v2/views/form-view/show-on-conditions.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

3
packages/nocodb/src/controllers/filters.controller.ts

@ -7,6 +7,7 @@ import {
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
@ -32,10 +33,12 @@ export class FiltersController {
async filterList(
@TenantContext() context: NcContext,
@Param('viewId') viewId: string,
@Query('includeAllFilters') includeAllFilters: string,
) {
return new PagedResponseImpl(
await this.filtersService.filterList(context, {
viewId,
includeAllFilters: includeAllFilters === 'true'
}),
);
}

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -49,6 +49,7 @@ import * as nc_059_invited_by from '~/meta/migrations/v2/nc_059_invited_by';
import * as nc_060_descriptions from '~/meta/migrations/v2/nc_060_descriptions';
import * as nc_061_integration_is_default from '~/meta/migrations/v2/nc_061_integration_is_default';
import * as nc_062_integration_store from '~/meta/migrations/v2/nc_062_integration_store';
import * as nc_063_form_field_filter from '~/meta/migrations/v2/nc_063_form_field_filter';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -109,6 +110,7 @@ export default class XcMigrationSourcev2 {
'nc_060_descriptions',
'nc_061_integration_is_default',
'nc_062_integration_store',
'nc_063_form_field_filter',
]);
}
@ -220,6 +222,8 @@ export default class XcMigrationSourcev2 {
return nc_061_integration_is_default;
case 'nc_062_integration_store':
return nc_062_integration_store;
case 'nc_063_form_field_filter':
return nc_063_form_field_filter;
}
}
}

16
packages/nocodb/src/meta/migrations/v2/nc_063_form_field_filter.ts

@ -0,0 +1,16 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.FILTER_EXP, (table) => {
table.string('fk_parent_column_id', 20).index();
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.FILTER_EXP, (table) => {
table.dropColumn('fk_parent_column_id');
});
};
export { up, down };

4
packages/nocodb/src/models/Column.ts

@ -957,6 +957,10 @@ export default class Column<T = any> implements ColumnType {
await Filter.delete(context, filter.id, ncMeta);
}
}
{
await Filter.deleteAllByParentColumn(context, id, ncMeta);
}
// Set Gallery & Kanban view `fk_cover_image_col_id` value to null
await Column.deleteCoverImageColumnId(context, id, ncMeta);

183
packages/nocodb/src/models/Filter.ts

@ -24,6 +24,7 @@ export default class Filter implements FilterType {
fk_model_id?: string;
fk_view_id?: string;
fk_hook_id?: string;
fk_parent_column_id?: string;
fk_column_id?: string;
fk_parent_id?: string;
fk_link_col_id?: string;
@ -40,6 +41,7 @@ export default class Filter implements FilterType {
base_id?: string;
source_id?: string;
column?: Column;
order?: number;
constructor(data: Filter | FilterType) {
Object.assign(this, data);
@ -73,7 +75,7 @@ export default class Filter implements FilterType {
public static async insert(
context: NcContext,
filter: Partial<FilterType> & { order?: number },
filter: Partial<FilterType>,
ncMeta = Noco.ncMeta,
) {
const insertObj = extractProps(filter, [
@ -82,6 +84,7 @@ export default class Filter implements FilterType {
'fk_hook_id',
'fk_link_col_id',
'fk_value_col_id',
'fk_parent_column_id',
'fk_column_id',
'comparison_op',
'comparison_sub_op',
@ -95,6 +98,7 @@ export default class Filter implements FilterType {
]);
const referencedModelColName = [
'fk_parent_column_id',
'fk_view_id',
'fk_hook_id',
'fk_link_col_id',
@ -106,7 +110,7 @@ export default class Filter implements FilterType {
if (!filter.source_id) {
let model: { base_id?: string; source_id?: string };
if (filter.fk_view_id) {
if (filter.fk_view_id && !filter.fk_parent_column_id) {
model = await View.get(context, filter.fk_view_id, ncMeta);
} else if (filter.fk_hook_id) {
model = await Hook.get(context, filter.fk_hook_id, ncMeta);
@ -116,6 +120,12 @@ export default class Filter implements FilterType {
{ colId: filter.fk_link_col_id },
ncMeta,
);
} else if (filter.fk_parent_column_id) {
model = await Column.get(
context,
{ colId: filter.fk_parent_column_id },
ncMeta,
);
} else if (filter.fk_column_id) {
model = await Column.get(
context,
@ -163,7 +173,7 @@ export default class Filter implements FilterType {
) {
if (!(id && (filter.fk_view_id || filter.fk_hook_id))) {
throw new Error(
`Mandatory fields missing in FITLER_EXP cache population : id(${id}), fk_view_id(${filter.fk_view_id}), fk_hook_id(${filter.fk_hook_id})`,
`Mandatory fields missing in FILTER_EXP cache population : id(${id}), fk_view_id(${filter.fk_view_id}), fk_hook_id(${filter.fk_hook_id})`,
);
}
const key = `${CacheScope.FILTER_EXP}:${id}`;
@ -199,6 +209,15 @@ export default class Filter implements FilterType {
),
);
}
if (filter.fk_parent_column_id) {
p.push(
NocoCache.appendToList(
CacheScope.FILTER_EXP,
[filter.fk_parent_column_id],
key,
),
);
}
if (filter.fk_parent_id) {
if (filter.fk_view_id) {
p.push(
@ -218,6 +237,15 @@ export default class Filter implements FilterType {
),
);
}
if (filter.fk_parent_column_id) {
p.push(
NocoCache.appendToList(
CacheScope.FILTER_EXP,
[filter.fk_parent_column_id, filter.fk_parent_id],
key,
),
);
}
p.push(
NocoCache.appendToList(
CacheScope.FILTER_EXP,
@ -435,16 +463,18 @@ export default class Filter implements FilterType {
viewId,
hookId,
linkColId,
parentColId,
}: {
viewId?: string;
hookId?: string;
linkColId?: string;
parentColId?: string;
},
ncMeta = Noco.ncMeta,
): Promise<FilterType> {
const cachedList = await NocoCache.getList(
CacheScope.FILTER_EXP,
[viewId || hookId || linkColId],
[parentColId || viewId || hookId || linkColId],
{
key: 'order',
},
@ -454,12 +484,14 @@ export default class Filter implements FilterType {
if (!isNoneList && !filters.length) {
const condition: Record<string, string> = {};
if (viewId) {
if (viewId && !parentColId) {
condition.fk_view_id = viewId;
} else if (hookId) {
condition.fk_hook_id = hookId;
} else if (linkColId) {
condition.fk_link_col_id = linkColId;
} else if (parentColId) {
condition.fk_parent_column_id = parentColId;
}
filters = await ncMeta.metaList2(
@ -476,7 +508,7 @@ export default class Filter implements FilterType {
await NocoCache.setList(
CacheScope.FILTER_EXP,
[viewId || hookId || linkColId],
[parentColId || viewId || hookId || linkColId],
filters,
);
}
@ -574,6 +606,32 @@ export default class Filter implements FilterType {
await deleteRecursively(filter);
}
static async deleteAllByParentColumn(
context: NcContext,
parentColId: string,
ncMeta = Noco.ncMeta,
) {
const filter = await this.getFilterObject(context, { parentColId }, ncMeta);
const deleteRecursively = async (filter) => {
if (!filter) return;
for (const f of filter?.children || []) await deleteRecursively(f);
if (filter.id) {
await ncMeta.metaDelete(
context.workspace_id,
context.base_id,
MetaTable.FILTER_EXP,
filter.id,
);
await NocoCache.deepDel(
`${CacheScope.FILTER_EXP}:${filter.id}`,
CacheDelDirection.CHILD_TO_PARENT,
);
}
};
await deleteRecursively(filter);
}
public static async get(
context: NcContext,
id: string,
@ -599,9 +657,33 @@ export default class Filter implements FilterType {
return this.castType(filterObj);
}
static async allViewFilterList(
context: NcContext,
{ viewId }: { viewId: string },
ncMeta = Noco.ncMeta,
) {
const cachedList = await NocoCache.getList(CacheScope.FILTER_EXP, [viewId]);
let { list: filterObjs } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !filterObjs.length) {
filterObjs = await ncMeta.metaList2(
context.workspace_id,
context.base_id,
MetaTable.FILTER_EXP,
{
condition: { fk_view_id: viewId },
},
);
await NocoCache.setList(CacheScope.FILTER_EXP, [viewId], filterObjs);
}
return filterObjs?.map((f) => this.castType(f)) || [];
}
static async rootFilterList(
context: NcContext,
{ viewId }: { viewId: any },
{ viewId }: { viewId: string },
ncMeta = Noco.ncMeta,
) {
const cachedList = await NocoCache.getList(
@ -613,6 +695,7 @@ export default class Filter implements FilterType {
);
let { list: filterObjs } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !filterObjs.length) {
filterObjs = await ncMeta.metaList2(
context.workspace_id,
@ -627,6 +710,7 @@ export default class Filter implements FilterType {
);
await NocoCache.setList(CacheScope.FILTER_EXP, [viewId], filterObjs);
}
return filterObjs
?.filter((f) => !f.fk_parent_id)
?.map((f) => this.castType(f));
@ -634,7 +718,7 @@ export default class Filter implements FilterType {
static async rootFilterListByHook(
context: NcContext,
{ hookId }: { hookId: any },
{ hookId }: { hookId: string },
ncMeta = Noco.ncMeta,
) {
const cachedList = await NocoCache.getList(
@ -663,12 +747,43 @@ export default class Filter implements FilterType {
?.map((f) => this.castType(f));
}
static async rootFilterListByParentColumn(
context: NcContext,
{ parentColId }: { parentColId: string },
ncMeta = Noco.ncMeta,
) {
const cachedList = await NocoCache.getList(
CacheScope.FILTER_EXP,
[parentColId],
{ key: 'order' },
);
let { list: filterObjs } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !filterObjs.length) {
filterObjs = await ncMeta.metaList2(
context.workspace_id,
context.base_id,
MetaTable.FILTER_EXP,
{
condition: { fk_parent_column_id: parentColId },
orderBy: {
order: 'asc',
},
},
);
await NocoCache.setList(CacheScope.FILTER_EXP, [parentColId], filterObjs);
}
return filterObjs
?.filter((f) => !f.fk_parent_id)
?.map((f) => this.castType(f));
}
static async parentFilterList(
context: NcContext,
{
parentId,
}: {
parentId: any;
parentId: string;
},
ncMeta = Noco.ncMeta,
) {
@ -705,8 +820,8 @@ export default class Filter implements FilterType {
hookId,
parentId,
}: {
hookId: any;
parentId: any;
hookId: string;
parentId: string;
},
ncMeta = Noco.ncMeta,
) {
@ -743,6 +858,50 @@ export default class Filter implements FilterType {
return filterObjs?.map((f) => this.castType(f));
}
static async parentFilterListByParentColumn(
context: NcContext,
{
parentColId,
parentId,
}: {
parentColId: string;
parentId: string;
},
ncMeta = Noco.ncMeta,
) {
const cachedList = await NocoCache.getList(
CacheScope.FILTER_EXP,
[parentColId, parentId],
{
key: 'order',
},
);
let { list: filterObjs } = cachedList;
const { isNoneList } = cachedList;
if (!isNoneList && !filterObjs.length) {
filterObjs = await ncMeta.metaList2(
context.workspace_id,
context.base_id,
MetaTable.FILTER_EXP,
{
condition: {
fk_parent_id: parentId,
fk_parent_column_id: parentColId,
},
orderBy: {
order: 'asc',
},
},
);
await NocoCache.setList(
CacheScope.FILTER_EXP,
[parentColId, parentId],
filterObjs,
);
}
return filterObjs?.map((f) => this.castType(f));
}
static async hasEmptyOrNullFilters(
context: NcContext,
baseId: string,
@ -787,7 +946,7 @@ export default class Filter implements FilterType {
static async rootFilterListByLink(
_context: NcContext,
{ columnId: _columnId }: { columnId: any },
{ columnId: _columnId }: { columnId: string },
_ncMeta = Noco.ncMeta,
) {
return [];

2
packages/nocodb/src/models/HookFilter.ts

@ -107,7 +107,7 @@ export default class Filter {
) {
if (!(id && filter.fk_view_id)) {
throw new Error(
`Mandatory fields missing in FITLER_EXP cache population : id(${id}), fk_view_id(${filter.fk_view_id}), fk_parent_id(${filter.fk_view_id})`,
`Mandatory fields missing in FILTER_EXP cache population : id(${id}), fk_view_id(${filter.fk_view_id}), fk_parent_id(${filter.fk_view_id})`,
);
}
const key = `${CacheScope.FILTER_EXP}:${id}`;

7
packages/nocodb/src/models/View.ts

@ -417,6 +417,7 @@ export default class View implements ViewType {
{
...extractProps(filter, [
'id',
'fk_parent_column_id',
'fk_column_id',
'comparison_op',
'comparison_sub_op',
@ -1761,7 +1762,10 @@ export default class View implements ViewType {
if (viewColumns) {
for (let i = 0; i < viewColumns.length; i++) {
const column = viewColumns[i];
const column =
view.type === ViewTypes.FORM
? prepareForDb(viewColumns[i])
: viewColumns[i];
insertObjs.push({
...extractProps(column, [
@ -2136,6 +2140,7 @@ export default class View implements ViewType {
filterInsertObjs.push({
...extractProps(filter, [
'fk_parent_column_id',
'fk_column_id',
'comparison_op',
'comparison_sub_op',

2
packages/nocodb/src/modules/jobs/jobs/export-import/duplicate.processor.ts

@ -408,7 +408,7 @@ export class DuplicateProcessor {
});
}
this.debugLog(`job completed for ${job.id} (${JobTypes.DuplicateModel})`);
this.debugLog(`job completed for ${job.id} (${JobTypes.DuplicateColumn})`);
return { id: findWithIdentifier(idMap, sourceColumn.id) };
}

3
packages/nocodb/src/modules/jobs/jobs/export-import/export.service.ts

@ -242,6 +242,9 @@ export class ExportService {
for (const fl of view.filter.children) {
const tempFl = {
id: `${idMap.get(view.id)}::${fl.id}`,
fk_parent_column_id: fl.fk_parent_column_id
? idMap.get(fl.fk_parent_column_id)
: null,
fk_column_id: idMap.get(fl.fk_column_id),
fk_parent_id: `${idMap.get(view.id)}::${fl.fk_parent_id}`,
is_group: fl.is_group,

1
packages/nocodb/src/modules/jobs/jobs/export-import/import.service.ts

@ -1344,6 +1344,7 @@ export class ImportService {
viewId: vw.id,
filter: withoutId({
...fl,
fk_parent_column_id: getIdOrExternalId(fl.fk_parent_column_id),
fk_column_id: getIdOrExternalId(fl.fk_column_id),
fk_parent_id: getIdOrExternalId(fl.fk_parent_id),
}),

17
packages/nocodb/src/schema/swagger-v2.json

@ -5933,6 +5933,13 @@
"parameters": [
{
"$ref": "#/components/parameters/xc-token"
},
{
"schema": {
"type": "boolean"
},
"name": "includeAllFilters",
"in": "query"
}
]
},
@ -14326,6 +14333,7 @@
"comparison_op": "eq",
"comparison_sub_op": null,
"created_at": "2023-03-02 18:18:05",
"fk_parent_column_id": "cd_d7ah9n2qfupgys",
"fk_column_id": "cl_d7ah9n2qfupgys",
"fk_hook_id": null,
"fk_parent_id": null,
@ -14425,6 +14433,10 @@
],
"description": "Comparison Sub-Operator"
},
"fk_parent_column_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to parent column"
},
"fk_column_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to Column"
@ -14479,6 +14491,11 @@
},
"value": {
"description": "The filter value. Can be NULL for some operators."
},
"order": {
"type": "number",
"description": "The order of the filter",
"example": 1
}
},
"readOnly": true,

17
packages/nocodb/src/schema/swagger.json

@ -6626,6 +6626,13 @@
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
},
{
"schema": {
"type": "boolean"
},
"name": "includeAllFilters",
"in": "query"
}
]
},
@ -20121,6 +20128,7 @@
"comparison_op": "eq",
"comparison_sub_op": null,
"created_at": "2023-03-02 18:18:05",
"fk_parent_column_id": "cd_d7ah9n2qfupgys",
"fk_column_id": "cl_d7ah9n2qfupgys",
"fk_hook_id": null,
"fk_parent_id": null,
@ -20220,6 +20228,10 @@
],
"description": "Comparison Sub-Operator"
},
"fk_parent_column_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to parent column"
},
"fk_column_id": {
"$ref": "#/components/schemas/StringOrNull",
"description": "Foreign Key to Column"
@ -20282,6 +20294,11 @@
},
"value": {
"description": "The filter value. Can be NULL for some operators."
},
"order": {
"type": "number",
"description": "The order of the filter",
"example": 1
}
},
"readOnly": true,

12
packages/nocodb/src/services/filters.service.ts

@ -134,10 +134,14 @@ export class FiltersService {
return filter;
}
async filterList(context: NcContext, param: { viewId: string }) {
const filter = await Filter.rootFilterList(context, {
viewId: param.viewId,
});
async filterList(
context: NcContext,
param: { viewId: string; includeAllFilters?: boolean },
) {
const filter = await (param.includeAllFilters
? Filter.allViewFilterList(context, { viewId: param.viewId })
: Filter.rootFilterList(context, { viewId: param.viewId }));
return filter;
}

60
tests/playwright/pages/Dashboard/Form/formConditionalFields.ts

@ -0,0 +1,60 @@
import { getTextExcludeIconText } from '../../../tests/utils/general';
import BasePage from '../../Base';
import { FormPage } from './index';
import { expect } from '@playwright/test';
export class FormConditionalFieldsPage extends BasePage {
readonly parent: FormPage;
constructor(parent: FormPage) {
super(parent.rootPage);
this.parent = parent;
}
get() {
return this.rootPage.getByTestId('nc-form-field-visibility-btn');
}
async click() {
await this.get().waitFor({ state: 'visible' });
await this.get().click();
await this.rootPage.getByTestId('nc-filter-menu').waitFor({ state: 'visible' });
}
async verify({ isDisabled, count, isVisible }: { isDisabled: boolean; count?: string; isVisible?: boolean }) {
const conditionalFieldBtn = this.get();
await conditionalFieldBtn.waitFor({ state: 'visible' });
if (isDisabled) {
await expect(conditionalFieldBtn).toHaveClass(/nc-disabled/);
} else {
await expect(conditionalFieldBtn).not.toHaveClass(/nc-disabled/);
}
if (count !== undefined) {
const conditionCount = await getTextExcludeIconText(conditionalFieldBtn);
await expect(conditionCount).toContain(count);
}
if (isVisible !== undefined) {
if (isVisible) {
}
}
}
async verifyVisibility({ title, isVisible }: { title: string; isVisible: boolean }) {
const field = this.parent.get().locator(`[data-testid="nc-form-fields"][data-title="${title}"]`);
await field.scrollIntoViewIfNeeded();
// Wait for icon change transition complete
await this.rootPage.waitForTimeout(300);
if (isVisible) {
await expect(field.locator('.nc-field-visibility-icon')).toHaveClass(/nc-field-visible/);
} else {
await expect(field.locator('.nc-field-visibility-icon')).not.toHaveClass(/nc-field-visible/);
}
}
}

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

@ -4,11 +4,13 @@ import { DashboardPage } from '..';
import BasePage from '../../Base';
import { ToolbarPage } from '../common/Toolbar';
import { TopbarPage } from '../common/Topbar';
import { FormConditionalFieldsPage } from './formConditionalFields';
export class FormPage extends BasePage {
readonly dashboard: DashboardPage;
readonly toolbar: ToolbarPage;
readonly topbar: TopbarPage;
readonly conditionalFields: FormConditionalFieldsPage;
// todo: All the locator should be private
readonly addOrRemoveAllButton: Locator;
@ -34,6 +36,7 @@ export class FormPage extends BasePage {
this.dashboard = dashboard;
this.toolbar = new ToolbarPage(this);
this.topbar = new TopbarPage(this);
this.conditionalFields = new FormConditionalFieldsPage(this);
this.addOrRemoveAllButton = dashboard
.get()
@ -970,4 +973,15 @@ export class FormPage extends BasePage {
},
};
}
async verifyFieldConfigError({ title, hasError }: { title: string; hasError: boolean }) {
const field = this.get().locator(`[data-testid="nc-form-fields"][data-title="${title}"]`);
await field.scrollIntoViewIfNeeded();
if (hasError) {
await expect(field.locator('.nc-field-config-error')).toBeVisible();
} else {
await expect(field.locator('.nc-field-config-error')).not.toBeVisible();
}
}
}

14
tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts

@ -34,7 +34,7 @@ export class ToolbarFilterPage extends BasePage {
}
async clickAddFilter() {
await this.get().locator(`button:has-text("Add Filter")`).first().click();
await this.get().getByTestId('add-filter').first().click();
}
// can reuse code for addFilterGroup and addFilter
@ -62,12 +62,12 @@ export class ToolbarFilterPage extends BasePage {
filterGroupIndex?: number;
filterLogicalOperator?: string;
}) {
await this.get().locator(`button:has-text("Add Filter Group")`).last().click();
await this.get().getByTestId('add-filter-group').last().click();
const filterDropdown = this.get().locator('.menu-filter-dropdown').nth(filterGroupIndex);
await filterDropdown.waitFor({ state: 'visible' });
const ADD_BUTTON_SELECTOR = `span:has-text("add")`;
const FILTER_GROUP_SUB_MENU_SELECTOR = `.nc-dropdown-filter-group-sub-menu`;
const ADD_FILTER_SELECTOR = `.nc-menu-item:has-text("Add Filter")`;
const ADD_FILTER_SELECTOR = `[data-testid="add-filter-menu"].nc-menu-item`;
await filterDropdown.locator(ADD_BUTTON_SELECTOR).first().click();
const filterGroupSubMenu = this.rootPage.locator(FILTER_GROUP_SUB_MENU_SELECTOR).last();
@ -132,7 +132,7 @@ export class ToolbarFilterPage extends BasePage {
skipWaitingResponse?: boolean;
}) {
if (!openModal) {
await this.get().locator(`button:has-text("Add Filter")`).first().click();
await this.get().getByTestId('add-filter').first().click();
}
const filterCount = await this.get().locator('.nc-filter-wrapper').count();
@ -164,9 +164,9 @@ export class ToolbarFilterPage extends BasePage {
}
}
const selectedOpType = await getTextExcludeIconText(this.rootPage.locator('.nc-filter-operation-select'));
const selectedOpType = await getTextExcludeIconText(this.rootPage.locator('.nc-filter-operation-select').last());
if (selectedOpType !== operation) {
await this.rootPage.locator('.nc-filter-operation-select').click();
await this.rootPage.locator('.nc-filter-operation-select').last().click();
// first() : filter list has >, >=
if (skipWaitingResponse || filterCount === 1) {
@ -448,7 +448,7 @@ export class ToolbarFilterPage extends BasePage {
}
async columnOperatorList(param: { columnTitle: string }) {
await this.get().locator(`button:has-text("Add Filter")`).first().click();
await this.get().getByTestId('add-filter').first().click();
const selectedField = await this.rootPage.locator('.nc-filter-field-select').textContent();
if (selectedField !== param.columnTitle) {

10
tests/playwright/pages/SharedForm/index.ts

@ -107,4 +107,14 @@ export class SharedFormPage extends BasePage {
},
};
}
async verifyField({ title, isVisible }: { title: string; isVisible: boolean }) {
const field = this.fieldLabel({ title });
if (isVisible) {
await expect(field).toBeVisible();
} else {
await expect(field).not.toBeVisible();
}
}
}

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

@ -1434,3 +1434,258 @@ test.describe('Form view: field validation', () => {
});
});
});
test.describe('Form view: conditional fields', () => {
if (enableQuickRun() || !isEE()) test.skip();
let dashboard: DashboardPage;
let form: FormPage;
let context: any;
let api: Api<any>;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.base);
form = dashboard.form;
});
test.afterEach(async () => {
await unsetup(context);
});
async function createTable({ tableName }: { tableName: string; type?: 'limitToRange' | 'attachment' }) {
api = new Api({
baseURL: 'http://localhost:8080/',
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Text',
title: 'Text',
uidt: UITypes.SingleLineText,
},
{
column_name: 'LongText',
title: 'LongText',
uidt: UITypes.LongText,
},
{
column_name: 'Email',
title: 'Email',
uidt: UITypes.Email,
},
{
column_name: 'PhoneNumber',
title: 'PhoneNumber',
uidt: UITypes.PhoneNumber,
meta: {
validate: true,
},
},
{
column_name: 'SingleSelect',
title: 'SingleSelect',
uidt: 'SingleSelect',
dtxp: "'jan','feb', 'mar','apr', 'may','jun','jul','aug','sep','oct','nov','dec'",
},
{
column_name: 'MultiSelect',
title: 'MultiSelect',
uidt: 'MultiSelect',
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
{
column_name: 'Number',
title: 'Number',
uidt: 'Number',
},
{
column_name: 'Decimal',
title: 'Decimal',
uidt: 'Decimal',
},
{
column_name: 'Currency',
title: 'Currency',
uidt: 'Currency',
meta: {
currency_locale: 'en-GB',
currency_code: 'UGX',
},
},
{
column_name: 'Percent',
title: 'Percent',
uidt: 'Percent',
},
{
column_name: 'Duration',
title: 'Duration',
uidt: 'Duration',
meta: {
duration: 0,
},
},
];
const base = await api.base.read(context.base.id);
await api.source.tableCreate(context.base.id, base.sources?.[0].id, {
table_name: tableName,
title: tableName,
columns: columns,
});
await dashboard.rootPage.reload();
await dashboard.rootPage.waitForTimeout(100);
await dashboard.treeView.openTable({ title: tableName });
await dashboard.rootPage.waitForTimeout(500);
await dashboard.viewSidebar.createFormView({ title: 'NewForm' });
}
test('Form builder conditional field', async () => {
await createTable({ tableName: 'FormConditionalFields' });
const url = dashboard.rootPage.url();
await form.configureHeader({
title: 'Form conditional fields',
subtitle: 'Test form conditional fields',
});
await form.verifyHeader({
title: 'Form conditional fields',
subtitle: 'Test form conditional fields',
});
// 1. Verify first field conditions btn is disabled: we can't add conditions on first form field
await form.selectVisibleField({ title: 'Text' });
await form.conditionalFields.verify({ isDisabled: true });
await form.selectVisibleField({ title: 'Decimal' });
const fieldConditionsList = [
{ column: 'Text', op: 'is equal', value: 'Spain', dataType: UITypes.SingleLineText },
{ column: 'Email', op: 'is not equal', value: 'user@nocodb.com', dataType: UITypes.Email },
{ column: 'Number', op: '=', value: '22', dataType: UITypes.Number },
];
await form.conditionalFields.click();
for (let i = 0; i < fieldConditionsList.length; i++) {
await form.toolbar.filter.add({
title: fieldConditionsList[i].column,
operation: fieldConditionsList[i].op,
// subOperation: param.opSubType,
value: fieldConditionsList[i].value,
locallySaved: false,
dataType: fieldConditionsList[i].dataType,
openModal: false,
skipWaitingResponse: true,
});
}
await form.conditionalFields.click();
await form.conditionalFields.verify({ isDisabled: false, count: '3' });
await form.conditionalFields.verifyVisibility({ title: 'Decimal', isVisible: false });
await form.fillForm([{ field: 'Text', value: 'Spain' }]);
await form.fillForm([{ field: 'Email', value: 'user1@nocodb.com' }]);
await form.conditionalFields.verifyVisibility({ title: 'Decimal', isVisible: false });
await form.fillForm([{ field: 'Number', value: '22' }]);
await form.conditionalFields.verifyVisibility({ title: 'Decimal', isVisible: true });
await form.formHeading.scrollIntoViewIfNeeded();
await form.formHeading.click();
// reorder & verify error
await form.reorderFields({
sourceField: 'Number',
destinationField: 'Currency',
});
await form.verifyFieldConfigError({ title: 'Decimal', hasError: true });
await form.reorderFields({
sourceField: 'Number',
destinationField: 'Decimal',
});
await form.verifyFieldConfigError({ title: 'Decimal', hasError: false });
// hide & verify error
await form.formHeading.scrollIntoViewIfNeeded();
await form.formHeading.click();
await form.removeField({ field: 'Text', mode: 'hideField' });
await form.verifyFieldConfigError({ title: 'Decimal', hasError: true });
await form.conditionalFields.verifyVisibility({ title: 'Decimal', isVisible: true });
await form.removeField({ field: 'Text', mode: 'hideField' });
await form.verifyFieldConfigError({ title: 'Decimal', hasError: false });
await form.conditionalFields.verifyVisibility({ title: 'Decimal', isVisible: true });
await form.fillForm([{ field: 'Email', value: 'user@nocodb.com' }]);
await form.conditionalFields.verifyVisibility({ title: 'Decimal', isVisible: false });
await dashboard.rootPage.waitForTimeout(5000);
// Shared form view
const formLink = await dashboard.form.topbar.getSharedViewUrl();
await dashboard.rootPage.goto(formLink);
// fix me! kludge@hub; page wasn't getting loaded from previous step
await dashboard.rootPage.reload();
const sharedForm = new SharedFormPage(dashboard.rootPage);
await sharedForm.verifyField({ title: 'Decimal', isVisible: false });
await sharedForm.cell.fillText({
columnHeader: 'Text',
text: 'Spain',
type: UITypes.SingleLineText,
});
await sharedForm.cell.fillText({
columnHeader: 'Email',
text: 'user1@nocodb.com',
type: UITypes.Email,
});
await sharedForm.cell.fillText({
columnHeader: 'Number',
text: '22',
type: UITypes.Number,
});
await sharedForm.verifyField({ title: 'Decimal', isVisible: true });
await sharedForm.submit();
await sharedForm.verifySuccessMessage();
await dashboard.rootPage.goto(url);
// kludge- reload
await dashboard.rootPage.reload();
});
});

Loading…
Cancel
Save