mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
462 lines
15 KiB
462 lines
15 KiB
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<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 |
|
isSharedForm: boolean |
|
isMysql?: (sourceId?: string) => boolean |
|
getMeta?: (tableIdOrTitle: string) => Promise<TableType | null> |
|
|
|
constructor({ |
|
data = [], |
|
nestedGroupedFilters = {}, |
|
formViewColumns = [], |
|
formViewColumnsMapByFkColumnId = {}, |
|
formState = {}, |
|
isMysql = undefined, |
|
isSharedForm = false, |
|
getMeta = undefined, |
|
}: { |
|
data?: FilterType[] |
|
nestedGroupedFilters?: Record<string, FilterType[]> |
|
formViewColumns?: FormViewColumn[] |
|
formViewColumnsMapByFkColumnId?: Record<string, FormViewColumn> |
|
formState?: Record<string, any> |
|
isMysql?: (sourceId?: string) => boolean |
|
isSharedForm?: boolean |
|
getMeta?: (tableIdOrTitle: string) => Promise<TableType | null> |
|
} = {}) { |
|
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<string, string>, |
|
): Promise<boolean | undefined> { |
|
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<string, boolean> = {} |
|
|
|
for (const column of this.formViewColumns) { |
|
const columnFilters = this.nestedGroupedFilters[column.fk_column_id] ?? [] |
|
|
|
const errors: Record<string, string> = {} |
|
|
|
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() {} |
|
}
|
|
|