tablor-core
Version:
Core features for data tables, grids, and advanced search, pagination, and sorting in Angular.
1,354 lines (1,342 loc) • 134 kB
JavaScript
import { Subject } from 'rxjs';
/**
* Utility functions for working with items.
*
* @remarks
* This class contains methods that are useful when working with items.
* Items are the data records managed by the data table library.
*/
class ItemsUtils {
/**
* Adds `tablorMeta` properties to each item in the items.
* @param items - The items to extend.
* @param getUuidAutoCounter - A function to generate unique UUIDs for items.
* @returns Augmented items with added `tablorMeta` properties.
*/
static augmentItems(items, getUuidAutoCounter) {
return items.map((data) => {
return {
...data,
tablorMeta: {
uuid: getUuidAutoCounter(),
isSelected: false,
isLoaded: true,
},
};
});
}
/**
* Finds the difference between two items.
* @param item1 - The first item.
* @param item2 - The second item.
* @returns The differences between the items.
*/
static getItemUpdates(item1, item2) {
const diff = {};
for (const key of Object.keys(item1)) {
if (key === 'tablorMeta')
continue;
else if (item1[key] !== item2[key])
diff[key] = item1[key];
}
// @ts-ignore
diff['tablorMeta'] = item2.tablorMeta;
return diff;
}
/**
* Checks if two items are equal.
* @param item1 - The first item.
* @param item2 - The second item.
* @returns `true` if items are equal, otherwise `false`.
*/
static itemsAreEqual(item1, item2) {
if (typeof item1 === 'number')
if (typeof item2 === 'number')
return item1 === item2;
else if (typeof item2 === 'object' && 'tablorMeta' in item2)
return item1 === item2.tablorMeta.uuid;
else
return false;
else if (typeof item2 === 'number')
if (typeof item1 === 'object' && 'tablorMeta' in item1)
return item1.tablorMeta.uuid === item2;
else
return false;
else if (typeof item1 === 'object' && typeof item2 === 'object')
for (const key of Object.keys(item1)) {
// Ignore tablorMeta property
if (key === 'tablorMeta')
continue;
if (item1[key] !== item2[key])
return false;
}
else if (item1 === undefined && item2 === undefined)
return true;
return false;
}
/**
* Merges a new item with an existing item, creating a new item.
* @param item1 - The new item with updated properties.
* @param item2 - The existing item to update.
* @returns The updated item.
*/
static mergeItemWith(item1, item2) {
const newItem = JSON.parse(JSON.stringify(item2));
for (const key of Object.keys(item1)) {
if (key === 'tablorMeta')
continue;
// @ts-expect-error
newItem[key] = item1[key];
}
return newItem;
}
/**
* Merges a new item with an existing item in place.
* @param item1 - The new item with updated properties.
* @param item2 - The existing item to update.
*/
static mergeItemInPlace(item1, item2) {
for (const key of Object.keys(item1)) {
if (key === 'tablorMeta')
continue;
// @ts-expect-error
item2[key] = item1[key];
}
}
/**
* Replaces the current dataset with a new dataset.
* @param dataRef - The reference to the existing dataset.
* @param newDataSet - The new dataset to replace the existing one.
* @param getUuidAutoCounter - A function to generate unique UUIDs for items.
*/
static replaceItemsInPlace(dataRef, newDataSet, getUuidAutoCounter) {
dataRef.splice(0, dataRef.length);
dataRef.push(...ItemsUtils.augmentItems(newDataSet, getUuidAutoCounter));
}
/**
* Removes items based on their UUIDs or data items.
* @param dataSetRef - The dataset to update.
* @param itemsOrUuids - The items or UUIDs to remove.
* @param indexPicker - A function to determine the index of items to remove.
* @returns The status of removals and removed items.
*/
static removeItemsInPlace(dataSetRef, itemsOrUuids, indexPicker) {
if (itemsOrUuids.length === 0)
return [[], []];
const uuidsRemovedStatus = Array(itemsOrUuids.length).fill(false);
const removedItems = [];
for (let _i = 0; _i < itemsOrUuids.length; _i++) {
const i = indexPicker(itemsOrUuids[_i], _i);
if (i === -1)
continue;
removedItems.push(dataSetRef[i]);
dataSetRef.splice(i, 1);
uuidsRemovedStatus[_i] = true;
}
return [uuidsRemovedStatus, removedItems];
}
/**
* Updates items in the dataset in place.
* @param dataRef - The dataset to update.
* @param itemIndexes - The indexes of items to update.
* @param modificationsInItems - The modifications to apply.
* @returns The status of modifications, modified items, and fields.
*/
static updateItemsInPlace(dataRef, itemIndexes, modificationsInItems) {
if (itemIndexes.length !== modificationsInItems.length)
throw new Error('The number of items and modifications must match');
if (itemIndexes.length === 0)
return [[], [], []];
const modificationsStatus = [];
modificationsStatus.length = itemIndexes.length;
const modifiedItems = [];
const modifiedFieldsInItems = [];
for (let i = 0; i < modificationsInItems.length; i++) {
const itemIndex = itemIndexes[i];
const modifications = modificationsInItems[i];
if (itemIndex < 0 || !modifications) {
modificationsStatus[i] = false;
continue;
}
if (itemIndex === -1) {
modificationsStatus[i] = false;
continue;
}
// Get the difference (which fields/properties to update) between the new item and the existing item
// ignore the same fields/properties
const itemsDifference = ItemsUtils.getItemUpdates(modifications, dataRef[itemIndex]);
// Whether there is any difference to update or not
// mark the item as updated
modificationsStatus[i] = true;
// if there is anything to update in the item, except the `tablorMeta`
if (Object.keys(itemsDifference).length <= 1) {
continue;
}
// Overwrite the item fields/properties with the new item fields/properties
ItemsUtils.mergeItemInPlace(modifications, dataRef[itemIndex]);
// Store the modified fields in the item
modifiedFieldsInItems.push(itemsDifference);
modifiedItems.push(dataRef[itemIndex]);
}
return [modificationsStatus, modifiedItems, modifiedFieldsInItems];
}
/**
* Filters an array of items by a specific field and value.
* @param dataSetRef - The array of items to filter.
* @param key - The field to check for the given value.
* @param value - The value to compare the field against.
* @returns An array of filtered items matching the key-value condition.
*/
filterItemsBy(dataSetRef, key, value) {
return dataSetRef.filter(item => item[key] === value);
}
/**
* Maps each item in the data to a new structure based on the provided field mappings.
* @param data - The data to map.
* @param fieldsArray - An array of fields that defines how to map each item.
* @param markMissingItemsUndefined - Whether to set missing fields to `undefined`.
* @returns A new array of mapped items.
*/
static mapItemsPropsToFields(data, fieldsArray, markMissingItemsUndefined) {
const mappedData = [];
for (const item of data) {
// @ts-ignore
const mappedItem = {};
for (const field of fieldsArray) {
if (field.key in item) {
// @ts-ignore
mappedItem[field.key] = item[field.key];
}
else if (markMissingItemsUndefined) {
// @ts-ignore
mappedItem[field.key] = undefined;
}
}
if ('tablorMeta' in item) {
// @ts-ignore
mappedItem['tablorMeta'] = item.tablorMeta;
}
mappedData.push(mappedItem);
}
return mappedData;
}
/**
* Finds the indexes of items that match the given UUIDs, items, or augmented items.
* @param dataRef - Dataset to search in.
* @param itemsOrUuids - Items or UUIDs to match against.
* @returns Array of indexes of matching items, or -1 for no match.
*
* @remarks
* - For UUIDs, matches are based on the `tablorMeta.uuid`.
* - For augmented items, matching is done using the UUID in `tablorMeta`.
* - For regular items, a deep equality check is performed.
*/
static findIndexes(dataRef, itemsOrUuids) {
if (itemsOrUuids.length === 0)
return [];
const indexes = [];
for (const itemOrUuid of itemsOrUuids) {
if (typeof itemOrUuid === 'number') {
indexes.push(dataRef.findIndex(item => item.tablorMeta.uuid === itemOrUuid));
}
else if (typeof itemOrUuid === 'object' && 'tablorMeta' in itemOrUuid) {
indexes.push(dataRef.findIndex(item => item.tablorMeta.uuid === itemOrUuid.tablorMeta.uuid));
}
else if (typeof itemOrUuid === 'object') {
indexes.push(dataRef.findIndex(item => ItemsUtils.itemsAreEqual(item, itemOrUuid)));
}
else {
indexes.push(-1);
}
}
return indexes;
}
/**
* Finds all matching indexes for the given UUIDs, items, or augmented items.
* @param dataRef - Dataset to search in.
* @param itemsOrUuids - UUIDs, items, or augmented items to match.
* @returns An array of arrays, each containing matching indexes for each item.
*
* @remarks
* - Matches all items with the given UUID.
* - For augmented items, matching is based on UUIDs within `tablorMeta`.
* - Regular items are matched using deep equality.
* - For unmatched items, an empty array is returned.
*/
static findAllIndexes(dataRef, itemsOrUuids) {
if (itemsOrUuids.length === 0)
return [];
const indexes = [];
for (const itemOrUuid of itemsOrUuids) {
if (typeof itemOrUuid !== 'number' &&
typeof itemOrUuid === 'object')
continue;
const currentIndexes = [];
dataRef.forEach((item, i) => {
if (ItemsUtils.itemsAreEqual(item, itemOrUuid))
currentIndexes.push(i);
});
// Add empty array if no match was found, or add the found indexes
indexes.push(currentIndexes);
}
return indexes;
}
/**
* Wraps a method to manage loading state during its execution.
* @param method - The method to wrap and handle the loading state for.
* @param loadingSetter - Function to update the loading state (true/false).
* @returns A wrapped method that manages the loading state.
*
* @remarks
* - Sets loading state to `true` before the method runs, and `false` afterward.
* - If an error occurs, loading state is set to `false`, and the error is thrown.
*/
static handleLoading(method, loadingSetter) {
return function (...args) {
loadingSetter(true);
let results = undefined;
try {
// Execute the original method with the provided arguments
results = method(...args);
loadingSetter(false);
return results;
}
catch (e) {
loadingSetter(false);
throw e;
}
};
}
}
/**
* Manages the items with methods for adding, removing, and updating them.
*/
class ItemsStore {
getFieldsAsArray;
allItems = [];
_uuidCounter = 0;
_loading = false;
$loadingStateChanged = new Subject();
$itemsAdded = new Subject;
$itemsRemoved = new Subject;
$itemsUpdated = new Subject;
constructor(getFieldsAsArray) {
this.getFieldsAsArray = getFieldsAsArray;
this.setLoading = this.setLoading.bind(this);
}
/**
* Returns the total number of items in the store.
*/
getNbOfItems() {
return this.allItems.length;
}
/**
* Returns the loading state.
*/
getLoadingState() {
return this._loading;
}
/**
* Returns all items as immutable objects.
*/
getItems(strictlyTyped = true) {
return this.allItems.map(item => item);
}
getMutableItems() {
return this.allItems;
}
/**
* Sets the loading state and triggers the corresponding event.
*/
setLoading(state) {
if (state === this._loading)
return;
this._loading = state;
this.$loadingStateChanged.next({ state });
}
/**
* Initializes the store with an array of items.
*/
initialize(items) {
if (this.allItems.length !== 0)
this.$itemsRemoved.next({ removedItems: this.allItems.map(item => item) });
if (items.length === 0)
return;
this.setLoading(true);
const fieldsArray = this.getFieldsAsArray();
ItemsUtils.replaceItemsInPlace(this.allItems,
// @ts-ignore
ItemsUtils.mapItemsPropsToFields(items, fieldsArray, true), this.getNewUuid.bind(this));
this.setLoading(false);
this.$itemsAdded.next({ addedItems: this.allItems.map(item => item) });
}
/**
* Adds new items to the store.
*/
add(items) {
if (items.length === 0)
return;
this.setLoading(true);
const fieldsArray = this.getFieldsAsArray();
const _items = ItemsUtils.augmentItems(
// @ts-ignore
ItemsUtils.mapItemsPropsToFields(items, fieldsArray, true), this.getNewUuid.bind(this));
this.allItems.push(..._items);
this.setLoading(false);
this.$itemsAdded.next({ addedItems: _items });
}
/**
* Removes items from the store by UUID or item reference.
*/
remove(itemsAndUuids) {
this.setLoading(true);
const indexPicker = (itemOrUuid) => {
return this.findOneIndexForEach([itemOrUuid])[0];
};
const [itemsRemovedStatus, removedItems] = ItemsUtils.removeItemsInPlace(this.allItems, itemsAndUuids, indexPicker);
this.setLoading(false);
if (removedItems.length !== 0) {
this.$itemsRemoved.next({ removedItems });
}
return itemsRemovedStatus;
}
/**
* Updates items in the store using augmented data.
*/
updateByInItemUuid(items) {
if (items.length === 0)
return [];
this.setLoading(true);
const indexes = this.findOneIndexForEach(items);
const fieldsArray = this.getFieldsAsArray();
const updateState = this.updateByIndex(ItemsUtils.mapItemsPropsToFields(items, fieldsArray, false), indexes);
this.setLoading(false);
return updateState;
}
/**
* Updates items in the store by matching UUIDs.
*/
updateByExternalUuids(items, uuids) {
if (items.length !== uuids.length)
throw new Error('The number of items and UUIDs must match');
if (items.length === 0)
return [];
this.setLoading(true);
const indexes = this.findOneIndexForEach(uuids);
const fieldsArray = this.getFieldsAsArray();
const updateState = this.updateByIndex(ItemsUtils.mapItemsPropsToFields(items, fieldsArray, false), indexes);
this.setLoading(false);
return updateState;
}
/**
* Updates items at specified indexes.
*/
updateByIndex(items, indexes) {
if (items.length !== indexes.length)
throw new Error('The number of items and indexes must match');
if (items.length === 0)
return [];
this.setLoading(true);
let unmodifiedItems = indexes.map(index => this.allItems[index]).filter(item => item !==
undefined);
unmodifiedItems = JSON.parse(JSON.stringify(unmodifiedItems));
const [modificationsStatus, modifiedItems, modifiedFieldsInItems] = ItemsUtils.updateItemsInPlace(this.allItems, indexes, items);
unmodifiedItems = unmodifiedItems.filter((_, index) => modificationsStatus[index]);
if (modifiedItems.length !== 0) {
this.$itemsUpdated.next({
updatedItems: modifiedItems,
prevUpdatedItems: unmodifiedItems,
updatedItemsDifference: modifiedFieldsInItems,
});
}
this.setLoading(false);
return modificationsStatus;
}
/**
* Finds and returns items matching the given UUIDs or item references.
*/
findOneMatchingItemForEach(itemsAndUuids) {
if (itemsAndUuids.length === 0)
return [];
return this.findOneIndexForEach(itemsAndUuids)
.map(index => index === -1 ? undefined : this.allItems[index]);
}
/**
* Finds and returns the indexes of items matching the given UUIDs or item references.
*/
findOneIndexForEach(itemsAndUuids) {
return ItemsUtils.findIndexes(this.allItems, itemsAndUuids);
}
/**
* Finds and returns all the possible indexes of items matching the given UUIDs or item references.
*/
findAllPossibleIndexesForEach(itemsAndUuids) {
return ItemsUtils.findAllIndexes(this.allItems, itemsAndUuids);
}
/**
* Generates a unique identifier (UUID) for items.
*/
getNewUuid() {
this._uuidCounter++;
return this._uuidCounter;
}
}
/**
* `FieldsStore` manages fields.
*/
class FieldsStore {
$fieldsChanged = new Subject();
_allFields = {};
/**
* Initialize with an event manager for handling field updates.
*/
constructor() {
}
/**
* Get all fields as an object.
*
* @returns The fields object.
*/
getFields() {
return this._allFields;
}
/**
* Get a field by key.
*/
getField(key) {
return this._allFields[key];
}
/**
* Get all field keys.
*/
getFieldsKeys() {
return Object.keys(this._allFields);
}
/**
* Check if a field exists.
*/
hasField(key) {
return key in this._allFields;
}
/**
* Get all fields as an array for easy iteration.
*
* @returns All the fields as array.
*/
getFieldsAsArray() {
return Object.keys(this._allFields).map(key => (
// @ts-ignore: Typescript ignore due to dynamic key access
{ ...this._allFields[key] }));
}
/**
* Initialize fields with provided configurations.
*
* @param fields - Initial field configurations.
*/
initialize(fields) {
const _fields = this.prepareFields(fields);
for (const key in _fields) {
this._allFields[key] = _fields[key];
}
}
/**
* Update fields.
*
* @param fields - Fields to update as object or array.
*/
updateFields(fields) {
const prevFields = {};
if (Array.isArray(fields)) {
for (const field of fields) {
if (!field.key)
throw new Error('Field must have a key.');
const updatedFieldValues =
// @ts-ignore
this.overwriteFieldInPlace(field, this._allFields[field.key]);
if (Object.keys(updatedFieldValues).length <= 1)
continue;
// @ts-ignore
prevFields[field.key] = { ...this._allFields[field.key], ...updatedFieldValues };
}
}
else if (typeof fields === 'object') {
for (const key in fields) {
// @ts-ignore
fields[key].key = key;
const updatedFieldValues = this.overwriteFieldInPlace(fields[key], this._allFields[key]);
if (Object.keys(updatedFieldValues).length <= 1)
continue;
prevFields[key] = { ...this._allFields[key], ...updatedFieldValues };
}
}
this.$fieldsChanged.next({
fields: this._allFields,
prevFields: { ...this._allFields, ...prevFields },
updatedFieldsKeys: Object.keys(prevFields),
});
}
/**
* Update a field by merging old configurations with new.
*
* @param field - Field to update.
* @param prevField - Previous field.
*/
overwriteFieldInPlace(field, prevField) {
if (!prevField)
return {};
let changed = {
key: prevField.key,
};
for (const key in field) {
if (key === 'key' || key === undefined)
continue;
if (field[key] !== prevField[key]) {
changed[key] = prevField[key];
prevField[key] = field[key];
}
}
return changed;
}
/**
* Prepare fields by applying defaults to missing values.
*
* @param fields - Fields to prepare.
*/
prepareFields(fields) {
const cols = {};
for (const key in fields) {
const col = fields[key];
cols[key] = {
key: key,
title: col.title !== undefined ? col.title : '',
colClasses: col.colClasses !== undefined ? col.colClasses : '',
isVisibleByDefault: col.isVisibleByDefault !== undefined ? col.isVisibleByDefault : true,
isSearchableByDefault: col.isSearchableByDefault !== undefined ? col.isSearchableByDefault : true,
isSortableByDefault: col.isSortableByDefault !== undefined ? col.isSortableByDefault : true,
isSortedByDefault: col.isSortedByDefault !== undefined ? col.isSortedByDefault : false,
isSortedReverseByDefault: col.isSortedReverseByDefault !== undefined ?
col.isSortedReverseByDefault :
false,
isSorted: col.isSortedByDefault !== undefined ? col.isSortedByDefault : false,
isSortedReverse: col.isSortedReverseByDefault !== undefined ? col.isSortedReverseByDefault : false,
isSearched: false,
isVisible: col.isVisibleByDefault !== undefined ? col.isVisibleByDefault : true,
render: col.render,
defaultContent: col.defaultContent !== undefined ? col.defaultContent : '-',
placeholderContent: col.placeholderContent !== undefined ? col.placeholderContent : '-',
};
}
return cols;
}
}
/**
* Represents a selector.
*/
class Selector {
getAllItems;
getPaginatedItems;
findOneIndexForEach;
$itemsRemoved;
_selectedUuids = [];
$itemsSelectionChanged = new Subject();
constructor(getAllItems, getPaginatedItems, findOneIndexForEach, $itemsRemoved) {
this.getAllItems = getAllItems;
this.getPaginatedItems = getPaginatedItems;
this.findOneIndexForEach = findOneIndexForEach;
this.$itemsRemoved = $itemsRemoved;
this.$itemsRemoved.subscribe(this.verifySelectedItemsOnRemoval.bind(this));
}
/**
* Returns the number of selected items.
*/
getNbOfSelectedItems() {
return this._selectedUuids.length;
}
getNbOfUnselectedItems() {
return this.getAllItems().length - this._selectedUuids.length;
}
getNbOfSelectedPaginatedItems() {
if (this.getAllItems().length === this.getPaginatedItems().length)
return this.getNbOfSelectedItems();
return this.getPaginatedItems().filter(item => this._selectedUuids.includes(item.tablorMeta.uuid)).length;
}
getNbOfUnselectedPaginatedItems() {
if (this.getAllItems().length === this.getPaginatedItems().length)
return this.getNbOfUnselectedItems();
return this.getPaginatedItems().filter(item => !this._selectedUuids.includes(item.tablorMeta.uuid)).length;
}
/**
* Returns the number of selected items in the given items.
*/
getNbOfSelectedItemsIn(items) {
return items.reduce((c, item) => {
if (item === undefined)
return c;
else if (typeof item === 'number')
return c + (this._selectedUuids.includes(item) ? 1 : 0);
else if (typeof item === 'object' && 'tablorMeta' in item)
return c + (this._selectedUuids.includes(item.tablorMeta.uuid) ? 1 : 0);
return c;
}, 0);
}
getSelectedItems() {
return this.getAllItems().filter(item => this._selectedUuids.includes(item.tablorMeta.uuid));
}
getUnselectedItems() {
return this.getAllItems().filter(item => !this._selectedUuids.includes(item.tablorMeta.uuid));
}
getSelectedItemUuids() {
return this._selectedUuids;
}
getUnselectedItemUuids() {
return this.getAllItems().map(item => item.tablorMeta.uuid).filter(uuid => !this._selectedUuids.includes(uuid));
}
getSelectedPaginatedItems() {
return this.getPaginatedItems().filter(item => this._selectedUuids.includes(item.tablorMeta.uuid));
}
getUnselectedPaginatedItems() {
return this.getPaginatedItems().filter(item => !this._selectedUuids.includes(item.tablorMeta.uuid));
}
/**
* Selects or deselects an item.
*/
select(item, state) {
const i = this.selectInternal(item, state);
if (i === -1)
return;
this.$itemsSelectionChanged.next({
items: [this.getAllItems()[i]],
});
}
/**
* Selects or deselects multiple items.
*/
selectMultiple(items, states) {
if (Array.isArray(states) && items.length !== states.length)
throw new Error('The number of items and states must match');
if (items.length === 0)
return;
const indexes = [];
if (!Array.isArray(states)) {
for (let i = 0; i < items.length; i++) {
indexes.push(this.selectInternal(items[i], states));
}
}
else {
for (let i = 0; i < items.length; i++) {
indexes.push(this.selectInternal(items[i], states[i]));
}
}
const selectedItems = indexes.map(i => this.getAllItems()[i]);
this.$itemsSelectionChanged.next({
items: selectedItems,
});
}
/**
* Verifies that the selected items are still valid after items have been removed.
*/
verifySelectedItemsOnRemoval({ removedItems }) {
const removedUuids = removedItems.map(item => item.tablorMeta.uuid);
this._selectedUuids = this._selectedUuids
.filter(uuid => !removedUuids.includes(uuid));
}
/**
* Selects or deselects an item.
*/
selectInternal(item, state) {
if (item === undefined)
return -1;
const i = this.findOneIndexForEach([item])[0];
if (i === -1)
return -1;
if (state === 'toggle')
state = !this.getAllItems()[i].tablorMeta.isSelected;
if (state) {
if (!this._selectedUuids.includes(this.getAllItems()[i].tablorMeta.uuid))
this._selectedUuids.push(this.getAllItems()[i].tablorMeta.uuid);
}
else {
if (this._selectedUuids.includes(this.getAllItems()[i].tablorMeta.uuid))
this._selectedUuids = this._selectedUuids
.filter(uuid => uuid !== this.getAllItems()[i].tablorMeta.uuid);
}
this.getAllItems()[i].tablorMeta.isSelected = state;
return i;
}
}
/**
* String query searcher.
*/
class StringQuerySearcher {
getFields;
hasField;
constructor(getFields, hasField) {
this.getFields = getFields;
this.hasField = hasField;
}
/**
* Processes string query options.
*/
processOptions(options) {
let includeFields;
let excludeFields = options.excludeFields || [];
if (options.includeFields === undefined)
includeFields = this.getFields()
.map(field => field.key)
.filter(field => !excludeFields.includes(field));
else
includeFields = options.includeFields;
const newOptions = {
query: options.query,
words: [],
includeFields: includeFields,
wordsInOrder: options.wordsInOrder === undefined ? false : options.wordsInOrder,
consecutiveWords: options.consecutiveWords === undefined ? false : options.consecutiveWords,
singleWordMatchCriteria: options.singleWordMatchCriteria !== undefined ?
options.singleWordMatchCriteria :
'Contains',
requireAllWords: options.requireAllWords === undefined ? true : options.requireAllWords,
convertToString: {
string: s => s,
null: undefined,
undefined: undefined,
boolean: undefined,
number: undefined,
date: undefined,
},
ignoreWhitespace: options.ignoreWhitespace === undefined ? true : options.ignoreWhitespace,
wordSeparators: options.wordSeparators === undefined ? [' '] : options.wordSeparators,
isCaseSensitive: options.isCaseSensitive === undefined ? false : options.isCaseSensitive,
};
if (options.convertToString) {
// @ts-ignore
newOptions.convertToString = {
string: undefined,
null: undefined,
undefined: undefined,
boolean: undefined,
number: undefined,
date: undefined,
...options.convertToString,
};
}
if (!options.query)
return newOptions;
newOptions.words = this.genQuerySplitterIntoWords(newOptions.wordSeparators, newOptions.ignoreWhitespace, newOptions.isCaseSensitive)(newOptions.query);
return newOptions;
}
/**
* Checks if the given options are valid.
*/
checkKeys(options) {
for (const field of options.includeFields) {
if (!this.hasField(field))
return false;
}
return true;
}
/**
* Search items by string query.
*/
search(items, options) {
if (options.words.length === 0)
return items;
if (options.includeFields.length === 0)
return [];
return this._search(items, options);
}
/**
* Search items by string query.
*/
_search(items, options) {
const searchedItems = [];
const splitQueryIntoWords = this.genQuerySplitterIntoWords(options.wordSeparators, options.ignoreWhitespace, options.isCaseSensitive);
const matchWords = this.genWordsMatcherFn(options);
const matchPhrases = this.genPhrasesMatcherFn(matchWords, options);
items.forEach((item) => {
const itemWords = [];
for (const field of options.includeFields) {
if (field === 'tablorMeta')
throw new Error('Cannot search by tablorMeta field');
const valueType = item[field] instanceof Date ? 'date' :
item[field] === null ? 'null' : typeof item[field];
// @ts-ignore
if (options.convertToString[valueType] === undefined)
continue;
// @ts-ignore
let value = options.convertToString[valueType](item[field]);
const valueWords = splitQueryIntoWords(value);
if (valueWords.length === 0)
continue;
itemWords.push(valueWords);
}
if (itemWords.length === 0)
return;
const itemPassed = matchPhrases(options.words, itemWords);
if (itemPassed)
searchedItems.push(item);
});
return searchedItems;
}
/**
* Generates field value extractor function.
*/
genQuerySplitterIntoWords(wordSeparators, ignoreWhitespace, isCaseSensitive) {
const wordsSeparatorFns = wordSeparators.map(separator => {
if (typeof separator === 'string')
return (query) => query.split(separator);
else if (typeof separator === 'function')
return separator;
else if (separator instanceof RegExp)
return (query) => query.split(separator);
else
throw new Error('Invalid word separator');
});
return (query) => {
let words = [query];
for (const separatorFn of wordsSeparatorFns) {
words = words.map(word => separatorFn(word)).flat();
}
if (ignoreWhitespace)
words = words.map(word => word.trim());
words = words.filter(word => word.length > 0);
if (!isCaseSensitive)
words = words.map(word => word.toLowerCase());
return words;
};
}
genWordsMatcherFn(options) {
switch (options.singleWordMatchCriteria) {
case 'ExactMatch':
return (subWord, word) => subWord === word;
case 'Contains':
return (subWord, word) => word.includes(subWord);
case 'StartsWith':
return (subWord, word) => word.startsWith(subWord);
case 'EndsWith':
return (subWord, word) => word.endsWith(subWord);
}
}
genPhrasesMatcherFn(wordsMatcherFn, options) {
if (options.requireAllWords) {
if (options.wordsInOrder) {
if (options.consecutiveWords) {
return (sw, iw) => {
// start with
// iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ]
// sw: f s wi: true
// sw: f s g: false
// sw: g c c: false
// sw: g c o: true
for (let itemFieldWords of iw) {
for (let fieldWordIndex = 0; fieldWordIndex <= itemFieldWords.length - sw.length; fieldWordIndex++) {
let match = true;
for (let searchWordIndex = 0; searchWordIndex < sw.length; searchWordIndex++) {
if (!wordsMatcherFn(sw[searchWordIndex], itemFieldWords[fieldWordIndex + searchWordIndex])) {
match = false;
break;
}
}
if (match)
return true;
}
}
return false;
};
}
else {
return (sw, iw) => {
// start with
// iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ]
// sw: f s wi: true
// sw: f s g: true
// sw: g c c: false
// sw: g c o: false
for (let itemFieldWords of iw) {
let currentIndex = 0;
let isMatched = true;
for (let i = 0; i < sw.length; i++) {
let found = false;
for (let j = currentIndex; j < itemFieldWords.length; j++) {
if (wordsMatcherFn(sw[i], itemFieldWords[j])) {
found = true;
currentIndex = j + 1;
break;
}
}
if (!found) {
isMatched = false;
break;
}
}
if (isMatched)
return true;
}
return false;
};
}
}
else {
if (options.consecutiveWords) {
const isPermutationMatch = (sw, slice, wordsMatcherFn) => {
const matchedIndices = new Set();
for (let i = 0; i < sw.length; i++) {
let found = false;
for (let j = 0; j < slice.length; j++) {
if (!matchedIndices.has(j) && wordsMatcherFn(sw[i], slice[j])) {
matchedIndices.add(j);
found = true;
break;
}
}
if (!found)
return false;
}
return true;
};
return (sw, iw) => {
// start with
// iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ]
// sw: wi f s: true
// sw: g f s: false
// sw: c c s: false
// sw: s c o: true
for (let itemFieldWords of iw) {
for (let i = 0; i <= itemFieldWords.length - sw.length; i++) {
const slice = itemFieldWords.slice(i, i + sw.length);
if (isPermutationMatch(sw, slice, wordsMatcherFn)) {
return true;
}
}
}
return false;
};
}
else {
return (sw, iw) => {
// start with
// iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ]
// sw: s c wi: true
// sw: s f g: true
return sw.every((word) => iw.flat().some((itemWord) => wordsMatcherFn(word, itemWord)));
};
}
}
}
else {
return (sw, iw) => {
// start with
// iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ]
// sw: s a t: true
// sw: a l l: false
// sw: o m m: true
return sw.some((word) => iw.flat().some((itemWord) => wordsMatcherFn(word, itemWord)));
};
}
}
}
function applyDateOffset(baseDate, offset) {
if (!offset)
return;
if (offset.years)
baseDate.setFullYear(baseDate.getFullYear() + offset.years);
if (offset.months)
baseDate.setMonth(baseDate.getMonth() + offset.months);
if (offset.days)
baseDate.setDate(baseDate.getDate() + offset.days);
if (offset.hours)
baseDate.setHours(baseDate.getHours() + offset.hours);
if (offset.minutes)
baseDate.setMinutes(baseDate.getMinutes() + offset.minutes);
if (offset.seconds)
baseDate.setSeconds(baseDate.getSeconds() + offset.seconds);
}
function convertToStrictDateRange(dateRange) {
const { start, startOffset, end, endOffset } = dateRange;
let strictStart = undefined;
if (start) {
if (typeof start === 'string') {
if (start === 'Now')
strictStart = new Date();
else
strictStart = new Date(start);
}
else {
strictStart = start;
}
applyDateOffset(strictStart, startOffset);
}
let strictEnd = undefined;
if (end) {
if (typeof end === 'string') {
if (end === 'Now')
strictEnd = new Date();
else
strictEnd = new Date(end);
}
else {
strictEnd = end;
}
applyDateOffset(strictEnd, endOffset);
}
// @ts-expect-error
return {
start: strictStart,
includeStart: strictStart ? (dateRange.includeStart ? dateRange.includeStart : false) : undefined,
end: strictEnd,
includeEnd: strictEnd ? (dateRange.includeEnd ? dateRange.includeEnd : false) : undefined,
};
}
/**
* `Date Range Searcher`. This class provides methods for searching items based on date ranges.
*/
class DateRangeSearcher {
hasField;
constructor(hasField) {
this.hasField = hasField;
}
/**
* Processes string query options.
*/
processOptions(options) {
const dateRanges = {};
for (const field in options.ranges) {
dateRanges[field] = options.ranges[field]
.map(dateRange => convertToStrictDateRange(dateRange));
}
return {
mustMatchAllFields: options.mustMatchAllFields !== undefined ? options.mustMatchAllFields : true,
ranges: dateRanges,
};
}
/**
* Checks if the given options are valid.
*/
checkKeys(options) {
for (const field in options.ranges) {
if (!this.hasField(field))
return false;
}
return true;
}
/**
* Searches items based on date ranges.
*/
search(items, options) {
const { ranges, mustMatchAllFields } = options;
return this.filterMatchingItemsUuids(ranges, mustMatchAllFields, items);
}
/**
* Filters items based on date ranges.
*/
filterMatchingItemsUuids(dateRanges, mustMatchAllFields, items) {
const searchedItems = [];
for (const item of items) {
let matchedFields = 0;
for (const field in dateRanges) {
const fieldDateRanges = dateRanges[field];
if (!fieldDateRanges || !fieldDateRanges.length)
continue;
const value = item[field];
if (!(value instanceof Date))
continue;
for (const range of fieldDateRanges) {
if (!value) {
if (!range.start && !range.end) {
matchedFields++;
break;
}
continue;
}
if (((range.start ? value > range.start : true) ||
(range.includeStart ? value >= range.start : false)) &&
((range.end ? value < range.end : true) ||
(range.includeEnd ? value <= range.end : false))) {
matchedFields++;
break;
}
}
}
if (matchedFields >= (mustMatchAllFields ? Object.keys(dateRanges).length : 0))
searchedItems.push(item);
}
return searchedItems;
}
}
/**
* Number ranges searcher. This class provides methods for searching items based on number ranges.
*/
class NumberRangesSearcher {
hasField;
constructor(hasField) {
this.hasField = hasField;
}
/**
* Processes string query options.
*/
processOptions(options) {
const newOptions = {
mustMatchAllFields: options.mustMatchAllFields !== undefined ? options.mustMatchAllFields : true,
ranges: {},
};
for (const field in options.ranges) {
const fieldNumberRanges = options.ranges[field];
if (!fieldNumberRanges || !fieldNumberRanges.length)
continue;
newOptions.ranges[field] = fieldNumberRanges.map(range => ({
min: range.min === undefined ? -Infinity : range.min,
max: range.max === undefined ? Infinity : range.max,
includeMin: range.includeMin !== undefined ? range.includeMin : false,
includeMax: range.includeMax !== undefined ? range.includeMax : false,
}));
}
return newOptions;
}
/**
* Checks if the given options are valid.
*/
checkKeys(options) {
for (const field in options.ranges) {
if (!this.hasField(field))
return false;
}
return true;
}
/**
* Searches items based on number ranges.
*/
search(items, options) {
const { ranges, mustMatchAllFields } = options;
return this.filterMatchingItemsUuids(ranges, mustMatchAllFields, items);
}
/**
* Filters items based on number ranges.
*/
filterMatchingItemsUuids(multiFieldsRanges, mustMatchAllFields, items) {
const searchedItems = [];
for (const item of items) {
let matchedFields = 0;
for (const field in multiFieldsRanges) {
const multiRanges = multiFieldsRanges[field];
if (!multiRanges || !multiRanges.length)
continue;
let value = item[field];
if (typeof value === 'string')
value = Number(value);
for (const range of multiRanges) {
if (!value) {
if (range.min === -Infinity && range.max === Infinity)
matchedFields++;
continue;
}
if (((range.min === -Infinity ? true : value > range.min) ||
(range.includeMin ? value === range.min : false)) &&
((range.max === Infinity ? true : value < range.max) ||
(range.includeMax ? value === range.max : false))) {
matchedFields++;
break;
}
}
}
if (matchedFields >= (mustMatchAllFields ? Object.keys(multiFieldsRanges).length : 0))
searchedItems.push(item);
}
return searchedItems;
}
}
/**
* Custom searcher. This searcher is used to filter items based on a custom function.
*/
class CustomSearcher {
constructor() { }
/**
* Processes the options.
*/
processOptions(options) {
return options;
}
/**
* Checks if the given options are valid.
*/
checkKeys() {
return true;
}
/**
* Filters items based on a custom function.
*/
search(items, options) {
return this.filterMatchingItemsUuids(options.customFn, items);
}
/**
* Filters items based on a custom function.
*/
filterMatchingItemsUuids(fn, items) {
const searchedItems = [];
for (const item of items) {
if (fn(item, items))
searchedItems.push(item);
}
return searchedItems;
}
}
/**
* `Void searcher`. This class provides methods for searching items based on void query functionality.
*/
class VoidSearcher {
constructor() { }
/**
* Processes string query options.
*/
processOptions() {
return {};
}
/**
* Checks if the given options are valid.
*/
checkKeys() {
return true;
}
/**
* Searches items based on void query functionality.
*/
search(items) {
return items;
}
}
/**
* `Exact values searcher`. This class provides methods for searching items based on exact values.
*/
class ExactValuesSearcher {
hasField;
constructor(hasField) {
this.hasField = hasField;
}
/**
* Processes string query options.
*/
processOptions(options) {
const p = {
values: options.values,
mustMatchAllFields: options.mustMatchAllFields !== undefined ? options.mustMatchAllFields : true,
customCompareFns: options.customCompareFns !== undefined ? options.customCompareFns : {},
};
for (const field in p.values) {
// @ts-ignore
p.customCompareFns[field] = p.customCompareFns[field] !== undefined ?
p.customCompareFns[field] :
((actualVal, expectedVal) => actualVal === expectedVal);
}
return p;
}
/**
* Checks if the given options are valid.
*/
checkKeys(options) {
for (const field in options.values) {
if (!this.hasField(field)) {
return false;
}
}
return true;
}
/**