1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-24 05:03:02 +00:00
bramw_baserow/web-frontend/modules/database/utils/view.js

545 lines
16 KiB
JavaScript

import { firstBy } from 'thenby'
import BigNumber from 'bignumber.js'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
import { escapeRegExp } from '@baserow/modules/core/utils/string'
import { SearchModes } from '@baserow/modules/database/utils/search'
import { convertStringToMatchBackendTsvectorData } from '@baserow/modules/database/search/regexes'
/**
* Generates a sort function based on the provided sortings.
*/
export function getRowSortFunction($registry, sortings, fields, groupBys = []) {
let sortFunction = firstBy()
const combined = [...groupBys, ...sortings]
combined.forEach((sort) => {
// Find the field that is related to the sort.
const field = fields.find((f) => f.id === sort.field)
if (field !== undefined) {
const fieldName = `field_${field.id}`
const fieldType = $registry.get('field', field.type)
const fieldSortFunction = fieldType.getSort(fieldName, sort.order, field)
sortFunction = sortFunction.thenBy(fieldSortFunction)
}
})
sortFunction = sortFunction.thenBy((a, b) =>
new BigNumber(a.order).minus(new BigNumber(b.order))
)
sortFunction = sortFunction.thenBy((a, b) => a.id - b.id)
return sortFunction
}
/**
* Generates a sort function for fields based on order and id.
*/
export function sortFieldsByOrderAndIdFunction(
fieldOptions,
primaryAlwaysFirst = false
) {
return (a, b) => {
if (primaryAlwaysFirst) {
// If primary must always be first, then first by primary.
if (a.primary > b.primary) {
return -1
} else if (a.primary < b.primary) {
return 1
}
}
const orderA = fieldOptions[a.id]
? fieldOptions[a.id].order
: maxPossibleOrderValue
const orderB = fieldOptions[b.id]
? fieldOptions[b.id].order
: maxPossibleOrderValue
// First by order.
if (orderA > orderB) {
return 1
} else if (orderA < orderB) {
return -1
}
// Then by id.
return a.id - b.id
}
}
/**
* Returns only fields that are visible (not hidden).
*/
export function filterVisibleFieldsFunction(fieldOptions) {
return (field) => {
const exists = Object.prototype.hasOwnProperty.call(fieldOptions, field.id)
return !exists || !fieldOptions[field.id].hidden
}
}
/**
* Returns only fields that are visible (not hidden).
*/
export function filterHiddenFieldsFunction(fieldOptions) {
return (field) => {
const exists = Object.prototype.hasOwnProperty.call(fieldOptions, field.id)
return exists && fieldOptions[field.id].hidden
}
}
/**
* Represents a node in a tree structure used for grouped filters.
* A group node is made of a filterType (AND or OR), a list of filters and a parent.
* If the parent is null it means that it is the root node. If the parent is not
* null it means that it is a child of the parent node, and the constructor will take care to
* add itself to the children of the parent node, so that we can later traverse the tree
* from the root node and check if a row matches the filters.
*/
export const TreeGroupNode = class {
/**
* Constructs a new TreeGroupNode.
*
* @param {string} filterType - The type of filter (e.g., 'AND' or 'OR').
* @param {TreeGroupNode} [parent=null] - The parent node of this node. Null for the root node.
*/
constructor(filterType, parent = null) {
this.filterType = filterType
this.parent = parent
this.filters = []
this.children = []
if (parent) {
parent.children.push(this)
}
}
/**
* Checks if this node or any of its descendants has filters.
*
* @returns {boolean} - True if there are filters, false otherwise.
*/
hasFilters() {
return this.filters.length > 0 || this.children.some((c) => c.hasFilters())
}
/**
* Adds a filter object to this node list of filters.
*
* @param {object} filter - The filter to add.
*/
addFilter(filter) {
this.filters.push(filter)
}
/**
* Serializes the filter tree rooted at this node. The serialized version will be in the form:
* {
* filter_type: 'AND' | 'OR',
* filters: [
* {
* type: 'contains' | 'does_not_contain' | 'is' | 'is_not' | 'is_empty' | 'is_not_empty' | etc.,
* field: 1,
* value: 'some value'
* },
* ...
* ],
* groups: [
* {
* filter_type: 'AND' | 'OR',
* filters: [
* type: 'contains' | 'does_not_contain' | 'is' | 'is_not' | 'is_empty' | 'is_not_empty' | etc.,
* field: 2,
* value: 'some other value'
* },
* ...
* ],
* groups: [...]
* },
* ...,
* ],
* }
*
* @returns {object} - The serialized tree.
*/
getFiltersTreeSerialized() {
const serialized = {
filter_type: this.filterType,
filters: [],
groups: [],
}
for (const filter of this.filters) {
serialized.filters.push({
type: filter.type,
field: filter.field,
value: filter.value,
})
}
for (const groupNode of this.children) {
serialized.groups.push(groupNode.getFiltersTreeSerialized())
}
return serialized
}
/**
* Determines if a given row matches the conditions of this node and its descendants.
* This function will recursively check if the row matches the filters of this node
* and its descendants. If the filter type of this node is 'AND' then it will return
* true if the row matches all the filters. If the filter type of this node is 'OR'
* then it will return true if the row matches at least one of the filters.
*
* @param {object} $registry - The registry containing field and filter type information.
* @param {Array} fields - The list of fields.
* @param {object} rowValues - The values of the row being checked.
* @returns {boolean} - True if the row matches, false otherwise.
*/
matches($registry, fields, rowValues) {
for (const child of this.children) {
const matches = child.matches($registry, fields, rowValues)
if (this.filterType === 'AND' && !matches) {
return false
} else if (this.filterType === 'OR' && matches) {
return true
}
}
for (const filter of this.filters) {
const filterValue = filter.value
const field = fields.find((f) => f.id === filter.field)
const fieldType = $registry.get('field', field.type)
const viewFilterType = $registry.get('viewFilter', filter.type)
const rowValue = rowValues[`field_${field.id}`]
const matches = viewFilterType.matches(
rowValue,
filterValue,
field,
fieldType
)
if (this.filterType === 'AND' && !matches) {
// With an `AND` filter type, the row must match all the filters, so if
// one of the filters doesn't match we can mark it as invalid.
return false
} else if (this.filterType === 'OR' && matches) {
// With an 'OR' filter type, the row only has to match one of the filters,
// that is the case here so we can mark it as valid.
return true
}
}
if (this.filterType === 'AND') {
// At this point with an `AND` condition the filter type matched all the
// filters and therefore we can mark it as valid.
return true
} else if (this.filterType === 'OR') {
// At this point with an `OR` condition none of the filters matched and
// therefore we can mark it as invalid.
return false
}
}
}
/**
* Creates a tree structure from given filters and filter groups. Groups are
* first sorted by ID because parent groups have smaller IDs since they were
* created before their children. In this way, we ensure that when a child node
* is added to the tree, its parent will already be present.
* Once the tree has been created, it adds all the filters to the respective
* groups.
*
* @param {string} filterType - The root filter type.
* @param {Array} filters - The list of filters.
* @param {Array} filterGroups - The list of filter groups.
* @returns {TreeGroupNode} - The root of the filter tree.
*/
export const createFiltersTree = (filterType, filters, filterGroups) => {
const rootGroup = new TreeGroupNode(filterType)
const filterGroupsById = { '': rootGroup }
const filterGroupsOrderedById = filterGroups
? [...filterGroups].sort((a, b) => a.id - b.id)
: []
for (const filterGroup of filterGroupsOrderedById) {
const parent = filterGroupsById[filterGroup.parent || '']
filterGroupsById[filterGroup.id] = new TreeGroupNode(
filterGroup.filter_type,
parent
)
}
for (const filter of filters) {
const filterGroupId = filter.group || ''
const filterGroup = filterGroupsById[filterGroupId]
filterGroup.addFilter(filter)
}
return rootGroup
}
/**
* A helper function that checks if the provided row values match the provided view
* filters. Returning false indicates that the row should not be visible for that
* view.
*/
export const matchSearchFilters = (
$registry,
filterType,
filters,
filterGroups,
fields,
values
) => {
// If there aren't any filters then it is not possible to check if the row
// matches any of the filters, so we can mark it as valid.
if (filters.length === 0) {
return true
}
const filterTree = createFiltersTree(filterType, filters, filterGroups)
return filterTree.matches($registry, fields, values)
}
function _fullTextSearch(registry, field, value, activeSearchTerm) {
const searchableString = registry
.get('field', field.type)
.toSearchableString(field, value)
const fixedValue = convertStringToMatchBackendTsvectorData(searchableString)
const fixedTerm = convertStringToMatchBackendTsvectorData(activeSearchTerm)
if (fixedTerm.length === 0) {
return false
} else {
const regexMatchingWordsThatStartWithTerm =
'(^|\\s+)' + escapeRegExp(fixedTerm)
return !!fixedValue.match(
new RegExp(regexMatchingWordsThatStartWithTerm, 'gu')
)
}
}
function _compatSearchMode(registry, field, value, activeSearchTerm) {
return registry
.get('field', field.type)
.containsFilter(value, activeSearchTerm, field)
}
export function valueMatchesActiveSearchTerm(
searchMode,
registry,
field,
value,
activeSearchTerm
) {
if (searchMode === SearchModes.MODE_FT_WITH_COUNT) {
return _fullTextSearch(registry, field, value, activeSearchTerm)
} else {
return _compatSearchMode(registry, field, value, activeSearchTerm)
}
}
function _findFieldsInRowMatchingSearch(
row,
activeSearchTerm,
fields,
registry,
overrides,
searchMode
) {
const fieldSearchMatches = new Set()
// If the row is loading then a temporary UUID is put in its id. We don't want to
// accidentally match against that UUID as it will be shortly replaced with its
// real id.
if (
!row._.loading &&
row.id?.toString() === (activeSearchTerm || '').trim()
) {
fieldSearchMatches.add('row_id')
}
for (const field of fields) {
const fieldName = `field_${field.id}`
const rowValue =
fieldName in overrides ? overrides[fieldName] : row[fieldName]
if (rowValue !== undefined && rowValue !== null) {
const doesMatch = valueMatchesActiveSearchTerm(
searchMode,
registry,
field,
rowValue,
activeSearchTerm
)
if (doesMatch) {
fieldSearchMatches.add(field.id.toString())
}
}
}
return fieldSearchMatches
}
/**
* Helper function which calculates if a given row and which of it's fields matches a
* given search term. The rows values can be overridden by providing an overrides
* object containing a mapping of the field name to override to a value that will be
* used to check for matches instead of the rows real one. The rows values will not be
* changed.
*/
export function calculateSingleRowSearchMatches(
row,
activeSearchTerm,
hideRowsNotMatchingSearch,
fields,
registry,
searchMode,
overrides = {}
) {
const searchIsBlank = activeSearchTerm === ''
const fieldSearchMatches = searchIsBlank
? new Set()
: _findFieldsInRowMatchingSearch(
row,
activeSearchTerm,
fields,
registry,
overrides,
searchMode
)
const matchSearch =
!hideRowsNotMatchingSearch || searchIsBlank || fieldSearchMatches.size > 0
return { row, matchSearch, fieldSearchMatches }
}
/**
* Returns true is the empty value of the provided field matches the active search term.
*/
export function newFieldMatchesActiveSearchTerm(
registry,
newField,
activeSearchTerm
) {
if (newField && activeSearchTerm !== '') {
const fieldType = registry.get('field', newField.type)
const emptyValue = fieldType.getEmptyValue(newField)
return valueMatchesActiveSearchTerm(
registry,
newField,
emptyValue,
activeSearchTerm
)
}
return false
}
export function getGroupBy(rootGetters, viewId) {
if (rootGetters['page/view/public/getIsPublic']) {
const view = rootGetters['view/get'](viewId)
return view.group_bys
.map((groupBy) => {
return `${groupBy.order === 'DESC' ? '-' : ''}field_${groupBy.field}`
})
.join(',')
} else {
return ''
}
}
export function getOrderBy(rootGetters, viewId) {
if (rootGetters['page/view/public/getIsPublic']) {
const view = rootGetters['view/get'](viewId)
return view.sortings
.map((sort) => {
return `${sort.order === 'DESC' ? '-' : ''}field_${sort.field}`
})
.join(',')
} else {
return ''
}
}
export function getFilters(rootGetters, viewId) {
const payload = {}
if (rootGetters['page/view/public/getIsPublic']) {
const view = rootGetters['view/get'](viewId)
if (!view.filters_disabled) {
const {
filter_type: filterType,
filter_groups: filterGroups,
filters,
} = view
const filterTree = createFiltersTree(filterType, filters, filterGroups)
if (filterTree.hasFilters()) {
const serializedTree = filterTree.getFiltersTreeSerialized()
payload.filters = [JSON.stringify(serializedTree)]
}
}
return payload
}
}
/**
* Calculates the size of a UTF-8 encoded string in bytes - computes the size
* of a string in UTF-8 encoding and utilizes the TextEncoder API if available.
*
* Using TextEncoder is preferred in Modern Browsers and Node.js Supported
* environments because it provides a more efficient and accurate way to encode
* strings into UTF-8 bytes and directly calculate the byte size of the encoded
* string.
*
* In some older web browsers or environments where TextEncoder may not be available
* (such as SSR where certain browser APIs are absent), it falls back to a less
* accurate method and simply returns the length of the string.
*/
export function utf8ByteSize(str) {
// Use TextEncoder if available (modern browsers and Node.js)
if (typeof TextEncoder !== 'undefined') {
const encoder = new TextEncoder()
const data = encoder.encode(str)
return data.length
} else {
// Fallback for older browsers (may not be as accurate)
return str.length
}
}
/**
* Limit the size of a cookie's value by removing elements from an array
* until it fits within the maximum allowed cookie size.
*/
export function fitInCookie(name, list) {
const result = []
for (let i = list.length - 1; i >= 0; i--) {
result.unshift(list[i])
const serialized = encodeURIComponent(JSON.stringify(result))
if (utf8ByteSize(serialized) > 4096) {
result.shift() // Remove the last added item as it caused the size to exceed the limit
break
}
}
return result
}
/**
* Return the view that has been visited most recently or the first
* available one that is capable of displaying the provided row data if required.
* If no view is available that can display the row data, return undefined.
*/
export function getDefaultView(app, store, workspaceId, showRowModal) {
// Put the most recently visited one first in the list.
const defaultView = store.getters['view/default']
const allViews = store.getters['view/getAllOrdered']
const views = defaultView ? [defaultView, ...allViews] : allViews
return views.find((view) => {
const viewType = app.$registry.get('view', view.type)
if (viewType.isDeactivated(workspaceId)) {
return false
}
// Ensure that the view can display the row data if required.
return showRowModal ? viewType.canShowRowModal() : true
})
}
/*
* Extracts the metadata from the provided data to populate the row.
*/
export function extractRowMetadata(data, rowId) {
const metadata = data.row_metadata || {}
return metadata[rowId] || {}
}