import type { ColumnType } from 'ant-design-vue/lib/table' import dayjs from 'dayjs' import { type FilterType, type LinkToAnotherRecordType, type TableType, UITypes, isDateMonthFormat } from 'nocodb-sdk' type FormViewColumn = ColumnType & Record export class FormFilters { allViewFilters: FilterType[] protected groupedFilters: Record nestedGroupedFilters: Record formViewColumns: FormViewColumn[] formViewColumnsMapByFkColumnId: Record formState: Record value: any isSharedForm: boolean isMysql?: (sourceId?: string) => boolean getMeta?: (tableIdOrTitle: string) => Promise constructor({ data = [], nestedGroupedFilters = {}, formViewColumns = [], formViewColumnsMapByFkColumnId = {}, formState = {}, isMysql = undefined, isSharedForm = false, getMeta = undefined, }: { data?: FilterType[] nestedGroupedFilters?: Record formViewColumns?: FormViewColumn[] formViewColumnsMapByFkColumnId?: Record formState?: Record isMysql?: (sourceId?: string) => boolean isSharedForm?: boolean getMeta?: (tableIdOrTitle: string) => Promise } = {}) { this.allViewFilters = data this.groupedFilters = {} this.nestedGroupedFilters = nestedGroupedFilters this.formViewColumns = formViewColumns this.formViewColumnsMapByFkColumnId = formViewColumnsMapByFkColumnId this.formState = formState this.isSharedForm = isSharedForm this.isMysql = isMysql this.getMeta = getMeta } setFilters(filters: FilterType[]) { this.allViewFilters = filters } getRootFilters(parentColId: string) { return (this.groupedFilters[parentColId] || []) .filter((f) => !f.fk_parent_id) .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) } getParentFilters(parentColId: string, parentId: string) { return (this.groupedFilters[parentColId] || []) .filter((f) => f.fk_parent_id === parentId) .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) } getAllChildFilters(filters: FilterType[], parentColId: string): any { return filters.map((filter) => { if (filter.id && filter.is_group) { const childFilters = this.getParentFilters(parentColId, filter.id) filter.children = this.getAllChildFilters(childFilters, parentColId) } return filter }) } loadFilters() { for (const parentColId in this.groupedFilters) { const rootFilters = this.getRootFilters(parentColId) this.nestedGroupedFilters[parentColId] = this.getAllChildFilters(rootFilters, parentColId) } return this.nestedGroupedFilters } // Method to group filters by fk_parent_column_id getNestedGroupedFilters() { const groupedFilters = this.allViewFilters.reduce((acc, filter) => { const groupingKey = filter.fk_parent_column_id || 'ungrouped' if (!acc[groupingKey]) { acc[groupingKey] = [] } acc[groupingKey].push(filter) return acc }, {} as typeof this.groupedFilters) this.groupedFilters = groupedFilters const nestedGroupedFilters = this.loadFilters() return nestedGroupedFilters } toString(value: any) { return `${value || ''}` } isFieldAboveParentColumn(column: FormViewColumn, parentColumn: FormViewColumn) { return column.order < parentColumn.order } async getOoOrBtColVal(column: FormViewColumn) { const fk_related_model_id = (column?.colOptions as LinkToAnotherRecordType)?.fk_related_model_id if (!fk_related_model_id || typeof this.getMeta !== 'function') return null const relatedTableMeta = await this.getMeta(fk_related_model_id) if (!relatedTableMeta || !Array.isArray(relatedTableMeta?.columns)) return null const displayValTitle = (relatedTableMeta.columns.find((c) => c.pv) || relatedTableMeta.columns?.[0])?.title || '' if ( !displayValTitle || !this.formState[column.title] || !ncIsObject(this.formState[column.title]) || this.formState[column.title][displayValTitle] === undefined ) { return null } return this.formState[column.title][displayValTitle] } async validateCondition( filters: FilterType[] = [], parentCol: FormViewColumn, errors: Record, ): Promise { if (!filters.length) { return true } let isValid for (const filter of filters) { let res if (filter.is_group) { res = await this.validateCondition(filter.children, parentCol, errors) } else { if (!filter.fk_column_id || !this.formViewColumnsMapByFkColumnId[filter.fk_column_id]) { res = false } const column = this.formViewColumnsMapByFkColumnId[filter.fk_column_id] // If the filter condition col is below parent column then this will be invalid condition so return false if (!this.isFieldAboveParentColumn(column, parentCol)) { errors[column.fk_column_id] = `Condition references a field (${column.title}) that comes later in the form.` res = true } if (!column.show) { errors[column.fk_column_id] = `Condition references a field (${column.title}) that was removed from the form.` res = true } if (!column?.visible) { res = false } const field = column.title let val = this.formState[field] if (res === undefined) { if ( [UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(column.uidt) && !['empty', 'blank', 'notempty', 'notblank'].includes(filter.comparison_op) ) { const dateFormat = this.isMysql?.(column.source_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' let now = dayjs(new Date()) const dateFormatFromMeta = column?.meta?.date_format const dataVal: any = val let filterVal: any = filter.value if (dateFormatFromMeta && isDateMonthFormat(dateFormatFromMeta)) { // reset to 1st now = dayjs(now).date(1) if (val) val = dayjs(val).date(1) } if (filterVal) res = dayjs(filterVal).isSame(dataVal, 'day') // handle sub operation switch (filter.comparison_sub_op) { case 'today': filterVal = now break case 'tomorrow': filterVal = now.add(1, 'day') break case 'yesterday': filterVal = now.add(-1, 'day') break case 'oneWeekAgo': filterVal = now.add(-1, 'week') break case 'oneWeekFromNow': filterVal = now.add(1, 'week') break case 'oneMonthAgo': filterVal = now.add(-1, 'month') break case 'oneMonthFromNow': filterVal = now.add(1, 'month') break case 'daysAgo': if (!filterVal) return filterVal = now.add(-filterVal, 'day') break case 'daysFromNow': if (!filterVal) return filterVal = now.add(filterVal, 'day') break case 'exactDate': if (!filterVal) return break // sub-ops for `isWithin` comparison case 'pastWeek': filterVal = now.add(-1, 'week') break case 'pastMonth': filterVal = now.add(-1, 'month') break case 'pastYear': filterVal = now.add(-1, 'year') break case 'nextWeek': filterVal = now.add(1, 'week') break case 'nextMonth': filterVal = now.add(1, 'month') break case 'nextYear': filterVal = now.add(1, 'year') break case 'pastNumberOfDays': if (!filterVal) return filterVal = now.add(-filterVal, 'day') break case 'nextNumberOfDays': if (!filterVal) return filterVal = now.add(filterVal, 'day') break } if (dataVal) { switch (filter.comparison_op) { case 'eq': res = dayjs(dataVal).isSame(filterVal, 'day') break case 'neq': res = !dayjs(dataVal).isSame(filterVal, 'day') break case 'gt': res = dayjs(dataVal).isAfter(filterVal, 'day') break case 'lt': res = dayjs(dataVal).isBefore(filterVal, 'day') break case 'lte': case 'le': res = dayjs(dataVal).isSameOrBefore(filterVal, 'day') break case 'gte': case 'ge': res = dayjs(dataVal).isSameOrAfter(filterVal, 'day') break case 'empty': case 'blank': res = dataVal === '' || dataVal === null || dataVal === undefined break case 'notempty': case 'notblank': res = !(dataVal === '' || dataVal === null || dataVal === undefined) break case 'isWithin': { let now = dayjs(new Date()).format(dateFormat).toString() now = column.uidt === UITypes.Date ? now.substring(0, 10) : now switch (filter.comparison_sub_op) { case 'pastWeek': case 'pastMonth': case 'pastYear': case 'pastNumberOfDays': res = dayjs(dataVal).isBetween(filterVal, now, 'day') break case 'nextWeek': case 'nextMonth': case 'nextYear': case 'nextNumberOfDays': res = dayjs(dataVal).isBetween(now, filterVal, 'day') break } } } } } else { switch (typeof filter.value) { case 'boolean': val = !!this.formState[field] break case 'number': val = Number.isNaN(parseFloat(this.formState[field])) ? this.formState[field] : +this.formState[field] break } switch (column.uidt) { case UITypes.Links: if (isMm(column) || isHm(column)) { val = (this.formState[field] ?? []).length } break case UITypes.LinkToAnotherRecord: if (isOo(column) || isBt(column)) { val = await this.getOoOrBtColVal(column) } break } switch (filter.comparison_op) { case 'eq': // eslint-disable-next-line eqeqeq res = val == filter.value break case 'neq': // eslint-disable-next-line eqeqeq res = val != filter.value break case 'like': res = this.toString(val).toLowerCase()?.includes(filter.value?.toLowerCase()) break case 'nlike': res = !this.toString(val).toLowerCase()?.includes(filter.value?.toLowerCase()) break case 'empty': case 'blank': res = val === '' || val === null || val === undefined break case 'notempty': case 'notblank': res = !(val === '' || val === null || val === undefined) break case 'checked': res = !!val break case 'notchecked': res = !val break case 'null': res = val === null break case 'notnull': res = val !== null break case 'allof': res = ( this.toString(filter.value) .split(',') .map((item) => item.trim()) ?? [] ).every((item) => (this.toString(val).split(',') ?? []).includes(item)) break case 'anyof': res = ( this.toString(filter.value) .split(',') .map((item) => item.trim()) ?? [] ).some((item) => (this.toString(val).split(',') ?? []).includes(item)) break case 'nallof': res = !( this.toString(filter.value) .split(',') .map((item) => item.trim()) ?? [] ).every((item) => (this.toString(val).split(',') ?? []).includes(item)) break case 'nanyof': res = !( this.toString(filter.value) .split(',') .map((item) => item.trim()) ?? [] ).some((item) => (this.toString(val).split(',') ?? []).includes(item)) break case 'lt': res = parseFloat(val) < +filter.value break case 'lte': case 'le': res = parseFloat(val) <= +filter.value break case 'gt': res = parseFloat(val) > +filter.value break case 'gte': case 'ge': res = parseFloat(val) >= +filter.value break } } } } switch (filter.logical_op) { case 'or': isValid = isValid || !!res break case 'not': isValid = isValid && !res break case 'and': default: isValid = (isValid ?? true) && res break } } return isValid } async validateVisibility() { const res: Record = {} for (const column of this.formViewColumns) { const columnFilters = this.nestedGroupedFilters[column.fk_column_id] ?? [] const errors: Record = {} const isValid = await this.validateCondition(columnFilters, column, errors) if (this.isSharedForm) { if (!column.meta?.preFilledHiddenField) { column.show = !!isValid column.visible = !!isValid } } else { column.visible = !!isValid column.meta = { ...parseProp(column.meta), visibility: { errors, }, } } } return res } validateErrors() {} }