@itwin/presentation-common
Version:
Common pieces for iModel.js presentation packages
415 lines • 18.5 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
* @module Content
*/
import { assert } from "@itwin/core-bentley";
import { RelatedClassInfo, RelatedClassInfoWithOptionalRelationship, } from "../EC.js";
import { omitUndefined } from "../Utils.js";
import { CategoryDescription } from "./Category.js";
import { Field, getFieldByDescriptor, getFieldByName, NestedContentField } from "./Fields.js";
/** @public */
export var SelectClassInfo;
(function (SelectClassInfo) {
/** Deserialize [[SelectClassInfo]] from compressed JSON */
function fromCompressedJSON(json, classesMap) {
assert(classesMap.hasOwnProperty(json.selectClassInfo));
return {
selectClassInfo: { id: json.selectClassInfo, ...classesMap[json.selectClassInfo] },
isSelectPolymorphic: json.isSelectPolymorphic,
...(json.navigationPropertyClasses
? { navigationPropertyClasses: json.navigationPropertyClasses.map((item) => RelatedClassInfo.fromCompressedJSON(item, classesMap)) }
: undefined),
...(json.relatedInstancePaths
? { relatedInstancePaths: json.relatedInstancePaths.map((rip) => rip.map((item) => RelatedClassInfo.fromCompressedJSON(item, classesMap))) }
: undefined),
...(json.pathFromInputToSelectClass
? {
pathFromInputToSelectClass: json.pathFromInputToSelectClass.map((item) => RelatedClassInfoWithOptionalRelationship.fromCompressedJSON(item, classesMap)),
}
: undefined),
...(json.relatedPropertyPaths
? { relatedPropertyPaths: json.relatedPropertyPaths.map((path) => path.map((item) => RelatedClassInfo.fromCompressedJSON(item, classesMap))) }
: undefined),
};
}
SelectClassInfo.fromCompressedJSON = fromCompressedJSON;
/** Serialize [[SelectClassInfo]] to compressed JSON */
function toCompressedJSON(selectClass, classesMap) {
const { id, ...leftOverClassInfo } = selectClass.selectClassInfo;
classesMap[id] = leftOverClassInfo;
return {
selectClassInfo: id,
isSelectPolymorphic: selectClass.isSelectPolymorphic,
...(selectClass.relatedInstancePaths
? { relatedInstancePaths: selectClass.relatedInstancePaths.map((rip) => rip.map((item) => RelatedClassInfo.toCompressedJSON(item, classesMap))) }
: undefined),
...(selectClass.navigationPropertyClasses
? {
navigationPropertyClasses: selectClass.navigationPropertyClasses.map((propertyClass) => RelatedClassInfo.toCompressedJSON(propertyClass, classesMap)),
}
: undefined),
...(selectClass.pathFromInputToSelectClass
? {
pathFromInputToSelectClass: selectClass.pathFromInputToSelectClass.map((item) => RelatedClassInfoWithOptionalRelationship.toCompressedJSON(item, classesMap)),
}
: undefined),
...(selectClass.relatedPropertyPaths
? {
relatedPropertyPaths: selectClass.relatedPropertyPaths.map((path) => path.map((relatedClass) => RelatedClassInfo.toCompressedJSON(relatedClass, classesMap))),
}
: undefined),
};
}
SelectClassInfo.toCompressedJSON = toCompressedJSON;
})(SelectClassInfo || (SelectClassInfo = {}));
/**
* Flags that control content format.
* @public
*/
export var ContentFlags;
(function (ContentFlags) {
/** Each content record only has [[InstanceKey]] and no data */
ContentFlags[ContentFlags["KeysOnly"] = 1] = "KeysOnly";
/** Each content record additionally has a display label */
ContentFlags[ContentFlags["ShowLabels"] = 4] = "ShowLabels";
/** All content records are merged into a single record (see [Merging values]($docs/presentation/content/terminology#value-merging)) */
ContentFlags[ContentFlags["MergeResults"] = 8] = "MergeResults";
/** Content has only distinct values */
ContentFlags[ContentFlags["DistinctValues"] = 16] = "DistinctValues";
/** Doesn't create property or calculated fields. Can be used in conjunction with [[ShowLabels]]. */
ContentFlags[ContentFlags["NoFields"] = 32] = "NoFields";
/**
* Set related input keys on [[Item]] objects when creating content. This helps identify which [[Item]] is associated to which
* given input key at the cost of performance creating those items.
*/
ContentFlags[ContentFlags["IncludeInputKeys"] = 256] = "IncludeInputKeys";
})(ContentFlags || (ContentFlags = {}));
/**
* Data sorting direction
* @public
*/
export var SortDirection;
(function (SortDirection) {
SortDirection[SortDirection["Ascending"] = 0] = "Ascending";
SortDirection[SortDirection["Descending"] = 1] = "Descending";
})(SortDirection || (SortDirection = {}));
/**
* Data structure that describes content: fields, sorting, filtering, format, etc.
* Descriptor may be changed to control how content is created.
*
* @public
*/
export class Descriptor {
/** Id of the connection used to create the descriptor */
connectionId;
/** Hash of the input keys used to create the descriptor */
inputKeysHash;
/** Selection info used to create the descriptor */
selectionInfo;
/** Display type used to create the descriptor */
displayType;
/** A list of classes that will be selected when creating content with this descriptor */
selectClasses;
/** A list of content field categories used in this descriptor */
categories;
/** A list of fields contained in the descriptor */
fields;
/** [[ContentFlags]] used to create the descriptor */
contentFlags;
/**
* A ruleset used to create this descriptor.
* Only set if descriptor is created using a ruleset different from the input ruleset, e.g. when creating a hierarchy level descriptor.
*/
ruleset;
/** Field used to sort the content */
sortingField;
/** Sorting direction */
sortDirection;
/**
* [ECExpression]($docs/presentation/advanced/ECExpressions.md) for filtering content by
* select fields.
*
* This is different from [[instanceFilter]] as filtering is applied on the union of all selects,
* which removes access to content instance property values. Instead of referencing properties
* through `this.PropertyName` alias, the expression should reference them by field names. In cases
* when properties field merges multiple properties, this allows applying the filter on all of them
* at once. This is useful for filtering table rows by column value, when content is displayed in
* table format.
*/
fieldsFilterExpression;
/**
* Instances filter that allows filtering content by class, properties of specific class
* or properties of instances related to the content instance.
*
* This is different from [[fieldsFilterExpression]] as filter is applied at a lower level - on
* specific select class rather than a union of multiple select classes. This means the filter has
* access to properties of that class and they can be referenced using symbols like `this.Property`.
* This is useful for filtering instances of specific class.
*/
instanceFilter;
#fieldsSelector;
#selectedFields;
/** Construct a new Descriptor using a [[DescriptorSource]] */
constructor(source) {
this.connectionId = source.connectionId;
this.inputKeysHash = source.inputKeysHash;
this.selectionInfo = source.selectionInfo;
this.displayType = source.displayType;
this.contentFlags = source.contentFlags;
this.selectClasses = [...source.selectClasses];
this.categories = [...source.categories];
this.fields = [...source.fields];
this.sortingField = source.sortingField;
this.sortDirection = source.sortDirection;
this.fieldsFilterExpression = source.fieldsFilterExpression;
this.instanceFilter = source.instanceFilter;
this.ruleset = source.ruleset;
this.#fieldsSelector = source.fieldsSelector;
}
/** Serialize [[Descriptor]] to JSON */
toJSON() {
const classesMap = {};
const selectClasses = this.selectClasses.map((selectClass) => SelectClassInfo.toCompressedJSON(selectClass, classesMap));
const fields = this.fields.map((field) => field.toCompressedJSON(classesMap));
return omitUndefined({
displayType: this.displayType,
contentFlags: this.contentFlags,
categories: this.categories.map(CategoryDescription.toJSON),
fields,
selectClasses,
classesMap,
connectionId: this.connectionId ?? "",
inputKeysHash: this.inputKeysHash ?? "",
sortingFieldName: this.sortingField?.name,
sortDirection: this.sortDirection,
fieldsFilterExpression: this.fieldsFilterExpression,
instanceFilter: this.instanceFilter,
selectionInfo: this.selectionInfo,
ruleset: this.ruleset,
});
}
/** Deserialize [[Descriptor]] from JSON */
static fromJSON(json) {
if (!json) {
return undefined;
}
const { categories: jsonCategories, selectClasses: jsonSelectClasses, fields: jsonFields, connectionId, inputKeysHash, classesMap, sortingFieldName, ...leftOverJson } = json;
const categories = CategoryDescription.listFromJSON(jsonCategories);
const selectClasses = jsonSelectClasses.map((jsc) => SelectClassInfo.fromCompressedJSON(jsc, classesMap));
const fields = this.getFieldsFromJSON(jsonFields, (fieldJson) => Field.fromCompressedJSON(fieldJson, classesMap, categories));
return new Descriptor({
...leftOverJson,
...(connectionId ? /* c8 ignore next */ { connectionId } : undefined),
...(inputKeysHash ? /* c8 ignore next */ { inputKeysHash } : undefined),
selectClasses,
categories,
fields,
sortingField: getFieldByName(fields, sortingFieldName, true),
});
}
static getFieldsFromJSON(json, factory) {
return json
.map((fieldJson) => {
const field = factory(fieldJson);
if (field) {
field.rebuildParentship();
}
return field;
})
.filter((field) => !!field);
}
/**
* Get field by its name
* @param name Name of the field to find
* @param recurse Recurse into nested fields
*/
getFieldByName(name, recurse) {
return getFieldByName(this.fields, name, recurse);
}
/**
* Get field by its descriptor.
*/
getFieldByDescriptor(fieldDescriptor, recurse) {
return getFieldByDescriptor(this.fields, fieldDescriptor, recurse);
}
/**
* Fields selector that allows excluding or including only specified fields. When set, the `selectedFields`
* property will return a subset of `fields` based on the selector configuration.
*/
get fieldsSelector() {
return this.#fieldsSelector;
}
set fieldsSelector(selector) {
this.#fieldsSelector = selector;
this.#selectedFields = undefined; // reset cached selected fields
}
/** Get selected fields based on `fields` in this descriptor and `fieldsSelector`. */
get selectedFields() {
if (!this.#fieldsSelector) {
return this.fields;
}
if (!this.#selectedFields) {
const { type, fields: selectedFields } = this.#fieldsSelector;
switch (type) {
case "include":
this.#selectedFields = exclusivelyIncludeFields(this.fields, selectedFields);
break;
case "exclude":
this.#selectedFields = excludeFields(this.fields, selectedFields);
break;
}
}
return this.#selectedFields;
}
/**
* Create descriptor overrides object from this descriptor.
* @public
*/
createDescriptorOverrides() {
const overrides = {};
if (this.displayType) {
overrides.displayType = this.displayType;
}
if (this.contentFlags !== 0) {
overrides.contentFlags = this.contentFlags;
}
if (this.fieldsFilterExpression) {
overrides.fieldsFilterExpression = this.fieldsFilterExpression;
}
if (this.instanceFilter) {
overrides.instanceFilter = this.instanceFilter;
}
if (this.sortingField) {
overrides.sorting = { field: this.sortingField.getFieldDescriptor(), direction: this.sortDirection ?? SortDirection.Ascending };
}
if (this.fieldsSelector) {
overrides.fieldsSelector = this.fieldsSelector;
}
return overrides;
}
}
/**
* Creates a shallow clone of a `NestedContentField` - copies its own properties but does not
* recursively deep-clone `nestedFields`. Instead, the children array is shallow-copied so it
* can be independently mutated without affecting the original.
*
* This avoids the cost of `NestedContentField.clone()` which deep-clones the entire subtree
* and calls `rebuildParentship` recursively - work that is wasted when callers immediately
* clear or modify the children.
*/
function shallowCloneNestedContentField(field) {
return new NestedContentField({
...field,
nestedFields: [...field.nestedFields],
});
}
function exclusivelyIncludeFields(descriptorFields, targetFieldDescriptors) {
const rootFields = [];
const clones = new Map();
for (const targetFieldDescriptor of targetFieldDescriptors) {
const includeField = getFieldByDescriptor(descriptorFields, targetFieldDescriptor, true);
if (!includeField) {
continue;
}
let clone;
let curr = includeField;
while (curr) {
const prev = clone;
const existingClone = clones.get(curr);
if (existingClone) {
// `curr` is already cloned, which means the fields hierarchy from root to `curr` is already
// built - we only need to add `prev` and that's it
if (prev) {
prev.rebuildParentship(existingClone);
existingClone.nestedFields.push(prev);
clone = undefined;
}
break;
}
// `curr` is not cloned yet
if (curr.isNestedContentField()) {
const nestedClone = shallowCloneNestedContentField(curr);
clones.set(curr, nestedClone);
nestedClone.nestedFields = [];
if (prev) {
prev.rebuildParentship(nestedClone);
nestedClone.nestedFields.push(prev);
}
clone = nestedClone;
}
else {
clone = curr.clone();
}
curr = curr.parent;
}
if (clone) {
rootFields.push(clone);
}
}
return rootFields;
}
function excludeFields(descriptorFields, targetFieldDescriptors) {
// Maps an original root field to its replacement (cloned field or `undefined` if root should be removed)
const rootReplacements = new Map();
// Tracks already-cloned NestedContentFields so we reuse the same clone when multiple excluded fields share a parent
const clones = new Map();
for (const targetFieldDescriptor of targetFieldDescriptors) {
const excludeField = getFieldByDescriptor(descriptorFields, targetFieldDescriptor, true);
if (!excludeField) {
continue;
}
// `curr` tracks the pair: (original field, its replacement clone or undefined if removed)
let curr = { original: excludeField, replacement: undefined };
let parent = excludeField.parent;
while (parent) {
let parentClone = clones.get(parent);
if (!parentClone) {
parentClone = shallowCloneNestedContentField(parent);
clones.set(parent, parentClone);
}
// Find the child in the parent clone's nestedFields that corresponds to curr.original
const childIndex = parentClone.nestedFields.findIndex((f) => f.name === curr.original.name);
if (childIndex !== -1) {
if (curr.replacement && curr.replacement.isNestedContentField() && curr.replacement.nestedFields.length > 0) {
// Replace the child with the modified clone
parentClone.nestedFields[childIndex] = curr.replacement;
curr.replacement.rebuildParentship(parentClone);
}
else {
// Remove the child
parentClone.nestedFields.splice(childIndex, 1);
}
}
curr = { original: parent, replacement: parentClone };
parent = parent.parent;
}
rootReplacements.set(curr.original, curr.replacement);
}
if (rootReplacements.size === 0) {
return descriptorFields;
}
const result = [];
for (const field of descriptorFields) {
if (!rootReplacements.has(field)) {
// Field is not affected by exclusions — keep as-is
result.push(field);
continue;
}
const replacement = rootReplacements.get(field);
if (replacement === undefined) {
// No replacement — field is removed entirely
continue;
}
if (replacement.isNestedContentField() && replacement.nestedFields.length === 0) {
// Replacement is an empty nested content field — remove it
continue;
}
// Valid replacement — use the clone
result.push(replacement);
}
return result;
}
//# sourceMappingURL=Descriptor.js.map