import { type ColumnType, type FilterType, type LinkToAnotherRecordType, type LookupType, type ViewType, getEquivalentUIType, } from 'nocodb-sdk' import type { ComputedRef, Ref } from 'vue' import type { SelectProps } from 'ant-design-vue' import { UITypes, isSystemColumn } from 'nocodb-sdk' export function useViewFilters( view: Ref, _parentId: Ref | null | string, autoApply?: ComputedRef, reloadData?: () => void, _currentFilters?: Filter[], isNestedRoot?: boolean, isWebhook?: boolean, ) { const parentId = ref(_parentId) const currentFilters = ref(_currentFilters) const btLookupTypesMap = ref({}) const reloadHook = inject(ReloadViewDataHookInj) const { nestedFilters, allFilters } = useSmartsheetStoreOrThrow() const { baseMeta } = storeToRefs(useBase()) const isPublic = inject(IsPublicInj, ref(false)) const { $api, $e } = useNuxtApp() const { isUIAllowed } = useRoles() const { metas, getMeta } = useMetas() const { addUndo, clone, defineViewScope } = useUndoRedo() const _filters = ref([]) const nestedMode = computed(() => isPublic.value || !isUIAllowed('filterSync') || !isUIAllowed('filterChildrenRead')) const filters = computed({ get: () => { return nestedMode.value ? currentFilters.value! : _filters.value }, set: (value: Filter[]) => { if (nestedMode.value) { currentFilters.value = value if (isNestedRoot) { nestedFilters.value = value } nestedFilters.value = [...nestedFilters.value] reloadHook?.trigger() return } _filters.value = value }, }) // when a filter is deleted with auto apply disabled, the status is marked as 'delete' // nonDeletedFilters are those filters that are not deleted physically & virtually const nonDeletedFilters = computed(() => filters.value.filter((f) => f.status !== 'delete')) const meta = inject(MetaInj, ref()) const activeView = inject(ActiveViewInj, ref()) const { showSystemFields, metaColumnById } = useViewColumnsOrThrow() const options = computed(() => meta.value?.columns?.filter((c: ColumnType) => { if (isSystemColumn(metaColumnById?.value?.[c.id!])) { /** hide system columns if not enabled */ return showSystemFields.value } else if (c.uidt === UITypes.QrCode || c.uidt === UITypes.Barcode || c.uidt === UITypes.ID || c.system) { return false } else { const isVirtualSystemField = c.colOptions && c.system return !isVirtualSystemField } }), ) const types = computed(() => { if (!meta.value?.columns?.length) { return {} } return meta.value?.columns?.reduce((obj: any, col: any) => { if (col.uidt === UITypes.Formula) { const formulaUIType = getEquivalentUIType({ formulaColumn: col, }) obj[col.id] = formulaUIType || col.uidt } // if column is a lookup column, then use the lookup type extracted from the column else if (btLookupTypesMap.value[col.id]) { obj[col.id] = btLookupTypesMap.value[col.id].uidt } else { obj[col.id] = col.uidt } return obj }, {}) }) const lastFilters = ref([]) watchOnce(filters, (filters: Filter[]) => { lastFilters.value = clone(filters) }) // get delta between two objects and return the changed fields (value is from b) const getFieldDelta = (a: any, b: any) => { return Object.entries(b) .filter(([key, val]) => a[key] !== val && key in a) .reduce((a, [key, v]) => ({ ...a, [key]: v }), {}) } const isComparisonOpAllowed = ( filter: FilterType, compOp: { text: string value: string ignoreVal?: boolean includedTypes?: UITypes[] excludedTypes?: UITypes[] }, ) => { const isNullOrEmptyOp = ['empty', 'notempty', 'null', 'notnull'].includes(compOp.value) const uidt = types.value[filter.fk_column_id] if (compOp.includedTypes) { // include allowed values only if selected column type matches if (filter.fk_column_id && compOp.includedTypes.includes(uidt)) { // for 'empty', 'notempty', 'null', 'notnull', // show them based on `showNullAndEmptyInFilter` in Base Settings return isNullOrEmptyOp ? baseMeta.value.showNullAndEmptyInFilter : true } else { return false } } else if (compOp.excludedTypes) { // include not allowed values only if selected column type not matches if (filter.fk_column_id && !compOp.excludedTypes.includes(uidt)) { // for 'empty', 'notempty', 'null', 'notnull', // show them based on `showNullAndEmptyInFilter` in Base Settings return isNullOrEmptyOp ? baseMeta.value.showNullAndEmptyInFilter : true } else { return false } } // explicitly include for non-null / non-empty ops return isNullOrEmptyOp ? baseMeta.value.showNullAndEmptyInFilter : true } const isComparisonSubOpAllowed = ( filter: FilterType, compOp: { text: string value: string ignoreVal?: boolean includedTypes?: UITypes[] excludedTypes?: UITypes[] }, ) => { const uidt = types.value[filter.fk_column_id] if (compOp.includedTypes) { // include allowed values only if selected column type matches return filter.fk_column_id && compOp.includedTypes.includes(uidt) } else if (compOp.excludedTypes) { // include not allowed values only if selected column type not matches return filter.fk_column_id && !compOp.excludedTypes.includes(uidt) } } const placeholderFilter = (): Filter => { const logicalOps = new Set(filters.value.slice(1).map((filter) => filter.logical_op)) return { comparison_op: comparisonOpList(options.value?.[0].uidt as UITypes).filter((compOp) => isComparisonOpAllowed({ fk_column_id: options.value?.[0].id }, compOp), )?.[0].value as FilterType['comparison_op'], value: null, status: 'create', logical_op: logicalOps.size === 1 ? logicalOps.values().next().value : 'and', } } const placeholderGroupFilter = (): Filter => { const logicalOps = new Set(filters.value.slice(1).map((filter) => filter.logical_op)) return { is_group: true, status: 'create', logical_op: logicalOps.size === 1 ? logicalOps.values().next().value : 'and', } } const loadAllChildFilters = async (filters: Filter[]) => { // Array to store promises of child filter loading const promises = [] // Array to store all child filters const allChildFilters: Filter[] = [] // Iterate over all filters for (const filter of filters) { // Check if the filter is a group if (filter.id && filter.is_group) { // Load children filters from the backend const childFilterPromise = $api.dbTableFilter.childrenRead(filter.id).then((response) => { const childFilters = response.list as Filter[] allChildFilters.push(...childFilters) return loadAllChildFilters(childFilters) }) promises.push(childFilterPromise) } } // Wait for all promises to resolve await Promise.all(promises) // Push all child filters into the allFilters array allFilters.value.push(...allChildFilters) } const loadFilters = async (hookId?: string, isWebhook = false, loadAllFilters = false) => { if (!view.value?.id) return if (nestedMode.value) { // ignore restoring if not root filter group return } try { if (isWebhook || hookId) { if (parentId.value) { filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[] } else if (hookId) { filters.value = (await $api.dbTableWebhookFilter.read(hookId)).list as Filter[] } } else { if (parentId.value) { filters.value = (await $api.dbTableFilter.childrenRead(parentId.value)).list as Filter[] } else { filters.value = (await $api.dbTableFilter.read(view.value!.id!)).list as Filter[] if (loadAllFilters) { allFilters.value = [...filters.value] await loadAllChildFilters(allFilters.value) } } } } catch (e: any) { console.log(e) message.error(await extractSdkResponseErrorMsg(e)) } } const sync = async (hookId?: string, _nested = false) => { try { for (const [i, filter] of Object.entries(filters.value)) { if (filter.status === 'delete') { await $api.dbTableFilter.delete(filter.id as string) if (filter.is_group) { deleteFilterGroupFromAllFilters(filter) } else { allFilters.value = allFilters.value.filter((f) => f.id !== filter.id) } } else if (filter.status === 'update') { await $api.dbTableFilter.update(filter.id as string, { ...filter, fk_parent_id: parentId.value, }) } else if (filter.status === 'create') { // extract children value if found to restore const children = filters.value[+i]?.children if (hookId) { filters.value[+i] = (await $api.dbTableWebhookFilter.create(hookId, { ...filter, fk_parent_id: parentId.value, })) as unknown as FilterType } else { filters.value[+i] = await $api.dbTableFilter.create(view?.value?.id as string, { ...filter, fk_parent_id: parentId.value, }) } if (children) filters.value[+i].children = children allFilters.value.push(filters.value[+i]) } } if (!isWebhook) reloadData?.() } catch (e: any) { console.log(e) message.error(await extractSdkResponseErrorMsg(e)) } } const saveOrUpdate = async (filter: Filter, i: number, force = false, undo = false, skipDataReload = false) => { if (!view.value) return if (!undo) { const lastFilter = lastFilters.value[i] if (lastFilter) { const delta = clone(getFieldDelta(filter, lastFilter)) if (Object.keys(delta).length > 0) { addUndo({ undo: { fn: (prop: string, data: any) => { const f = filters.value[i] if (f) { f[prop as keyof Filter] = data saveOrUpdate(f, i, force, true) } }, args: [Object.keys(delta)[0], Object.values(delta)[0]], }, redo: { fn: (prop: string, data: any) => { const f = filters.value[i] if (f) { f[prop as keyof Filter] = data saveOrUpdate(f, i, force, true) } }, args: [Object.keys(delta)[0], filter[Object.keys(delta)[0] as keyof Filter]], }, scope: defineViewScope({ view: activeView.value }), }) } } } try { if (nestedMode.value) { filters.value[i] = { ...filter } filters.value = [...filters.value] } else if (!autoApply?.value && !force) { filter.status = filter.id ? 'update' : 'create' } else if (filter.id && filter.status !== 'create') { await $api.dbTableFilter.update(filter.id, { ...filter, fk_parent_id: parentId.value, }) $e('a:filter:update', { logical: filter.logical_op, comparison: filter.comparison_op, }) } else { filters.value[i] = await $api.dbTableFilter.create(view.value.id!, { ...filter, fk_parent_id: parentId.value, }) allFilters.value.push(filters.value[+i]) } } catch (e: any) { console.log(e) message.error(await extractSdkResponseErrorMsg(e)) } lastFilters.value = clone(filters.value) if (!isWebhook && !skipDataReload) reloadData?.() } function deleteFilterGroupFromAllFilters(filter: Filter) { // Find all child filters of the specified parentId const childFilters = allFilters.value.filter((f) => f.fk_parent_id === filter.id) // Recursively delete child filter of child filter childFilters.forEach((childFilter) => { if (childFilter.is_group) { deleteFilterGroupFromAllFilters(childFilter) } }) // Remove the parent object and its children from the array allFilters.value = allFilters.value.filter((f) => f.id !== filter.id && f.fk_parent_id !== filter.id) } const deleteFilter = async (filter: Filter, i: number, undo = false) => { if (!undo && !filter.is_group) { addUndo({ undo: { fn: async (fl: Filter) => { fl.status = 'create' filters.value.splice(i, 0, fl) await saveOrUpdate(fl, i, false, true) }, args: [clone(filter)], }, redo: { fn: async (index: number) => { await deleteFilter(filters.value[index], index, true) }, args: [i], }, scope: defineViewScope({ view: activeView.value }), }) } // if shared or sync permission not allowed simply remove it from array if (nestedMode.value) { filters.value.splice(i, 1) filters.value = [...filters.value] if (!isWebhook) reloadData?.() } else { if (filter.id) { // if auto-apply disabled mark it as disabled if (!autoApply?.value) { filter.status = 'delete' // if auto-apply enabled invoke delete api and remove from array // no splice is required here } else { try { await $api.dbTableFilter.delete(filter.id) if (!isWebhook) reloadData?.() filters.value.splice(i, 1) } catch (e: any) { console.log(e) message.error(await extractSdkResponseErrorMsg(e)) } } // if not synced yet remove it from array } else { filters.value.splice(i, 1) } $e('a:filter:delete', { length: nonDeletedFilters.value.length }) } if (filter.is_group) { deleteFilterGroupFromAllFilters(filter) } else { allFilters.value = allFilters.value.filter((f) => f.id !== filter.id) } } const saveOrUpdateDebounced = useDebounceFn(saveOrUpdate, 500) const addFilter = async (undo = false, draftFilter: Partial = {}) => { filters.value.push(draftFilter?.fk_column_id ? { ...placeholderFilter(), ...draftFilter } : placeholderFilter()) if (!undo) { addUndo({ undo: { fn: async function undo(this: UndoRedoAction, i: number) { this.redo.args = [i, clone(filters.value[i])] await deleteFilter(filters.value[i], i, true) }, args: [filters.value.length - 1], }, redo: { fn: async (i: number, fl: Filter) => { fl.status = 'create' filters.value.splice(i, 0, fl) await saveOrUpdate(fl, i, false, true) }, args: [], }, scope: defineViewScope({ view: activeView.value }), }) } lastFilters.value = clone(filters.value) $e('a:filter:add', { length: filters.value.length }) } const addFilterGroup = async () => { const child = placeholderFilter() const placeHolderGroupFilter: Filter = placeholderGroupFilter() if (nestedMode.value) placeHolderGroupFilter.children = [child] filters.value.push(placeHolderGroupFilter) const index = filters.value.length - 1 await saveOrUpdate(filters.value[index], index) lastFilters.value = clone(filters.value) $e('a:filter:add', { length: filters.value.length, group: true }) } /** on column delete reload filters, identify by checking columns count */ watch( () => { if (!view?.value || !metas?.value?.[view?.value?.fk_model_id as string]) { return 0 } return metas?.value?.[view?.value?.fk_model_id as string]?.columns?.length || 0 }, async (nextColsLength: number, oldColsLength: number) => { if (nextColsLength && nextColsLength < oldColsLength) await loadFilters() }, ) // method to extract looked up column meta for all bt lookup columns // it helps to decide the condition operations for the column const loadBtLookupTypes = async () => { const btLookupTypes = {} try { for (const col of meta.value?.columns || []) { if (col.uidt !== UITypes.Lookup) continue let nextCol = col // check all the relation of nested lookup columns is bt or not // include the column only if all only if all relations are bt while (nextCol && nextCol.uidt === UITypes.Lookup) { // extract the relation column meta const lookupRelation = (await getMeta(nextCol.fk_model_id))?.columns?.find( (c) => c.id === (nextCol.colOptions as LookupType).fk_relation_column_id, ) const relatedTableMeta = await getMeta((lookupRelation.colOptions as LinkToAnotherRecordType).fk_related_model_id) nextCol = relatedTableMeta?.columns?.find((c) => c.id === (nextCol.colOptions as LookupType).fk_lookup_column_id) // if next column is same as root lookup column then break the loop // since it's going to be a circular loop if (nextCol.id === col.id) { break } } btLookupTypes[col.id] = nextCol } btLookupTypesMap.value = btLookupTypes } catch (e) { // ignore error since it is not blocking any functionality of the app console.error(e) } } return { filters, nonDeletedFilters, loadFilters, sync, deleteFilter, saveOrUpdate, addFilter, addFilterGroup, saveOrUpdateDebounced, isComparisonOpAllowed, isComparisonSubOpAllowed, loadBtLookupTypes, btLookupTypesMap, types, } }