import isEqual from 'lodash/isEqual'; import isNumber from 'lodash/isNumber'; import pick from 'lodash/pick'; import {renameProperty, renamePropertyValue} from '../../../report/util'; import {pluralize} from '../../../util'; import {formatDate, parseDate} from '../../../util/date'; import DateRange from '../../date-range-picker/models/date-range'; import {DateRangeType} from '../../date-range-picker/models/types'; import {getOperatorFromFilterOperator, getTypeCastOperatorFromType} from './selector-tree/utils'; import { BooleanOperator, FilterOperand, FilterOperator, FilterValue, ListOperator, NumberOperator, PropertyType, ResourceType, StringOperator, } from './types'; import { ArithmeticOperator, LogicalOperator, Operator, SerializedFilterOperand, SerializedFilterValue, SerializedResourceType, } from './selector-tree/types'; import ExpressionNode from './selector-tree/expression-node'; import OperandNode from './selector-tree/operand-node'; import OperatorNode from './selector-tree/operator-node'; const MAX_LABEL_ENTRIES = 3; const PROPERTY_FILTER_ATTRIBUTES: Array = [ `datasetId`, `dataGroupId`, `resourceType`, `propertyName`, `propertyDefaultType`, `propertyType`, `filterOperator`, `filterValue`, ]; const DEFAULT_FILTER_OPERATOR: {[key in PropertyType]?: FilterOperator} = { [PropertyType.Boolean]: BooleanOperator.True, [PropertyType.List]: ListOperator.Contains, [PropertyType.Number]: NumberOperator.Between, [PropertyType.String]: StringOperator.Equals, }; interface LabelPart { highlight: boolean; label: string; } const highlightPart = (label: string): LabelPart => ({highlight: true, label}); const plainPart = (label: string): LabelPart => ({highlight: false, label}); export default class PropertyFilter { public datasetId?: string; public dataGroupId?: string; public resourceType?: ResourceType; public propertyName?: string; public propertyDefaultType?: PropertyType; public propertyType?: PropertyType; public filterOperator?: FilterOperator; public filterValue?: FilterValue; constructor(attributes: Partial = {}) { attributes = attributes || {}; Object.assign(this, attributes); if (this.hasSelectedProperty() && !this.filterOperator) { Object.assign(this, this.getDefaultFilterAttributes({propertyType: attributes.propertyType})); } if (this.propertyType === PropertyType.Datetime && !(this.filterValue instanceof DateRange)) { this.filterValue = this.createDateRange(this.filterValue as any); } } public createDateRange(attributes: Partial = {}): DateRange { return new DateRange(attributes); } public getExpressionTree(): OperatorNode { let expression = null; const type = this.propertyType; if (type === PropertyType.Datetime) { expression = this.getValueOperatorNode( ArithmeticOperator.Datetime, (this.filterValue as DateRange).getApiFormat(), ); } else if (Array.isArray(this.filterValue)) { switch (this.propertyType) { case PropertyType.Number: let greaterThan: SerializedFilterOperand = null; let lessThan: SerializedFilterOperand = null; if (type === PropertyType.Number) { greaterThan = Math.min(...this.filterValue as Array); lessThan = Math.max(...this.filterValue as Array); } else { const [first, second] = this.filterValue.map(v => parseDate(v.toString())); greaterThan = formatDate(first < second ? first : second, {iso: true}); lessThan = formatDate(first < second ? second : first, {iso: true}); } expression = this.getBetweenOperatorExpressionTree(greaterThan, lessThan); break; case PropertyType.String: if (!this.filterValue.length || (this.filterOperator !== StringOperator.Equals && this.filterOperator !== StringOperator.DoesNotEqual) ) { throw new Error(`unexpected value array`); } // string is equal to or not equal to an array of values const logicalOperator = this.filterOperator === StringOperator.Equals ? LogicalOperator.Or : LogicalOperator.And; const operator = getOperatorFromFilterOperator(this.filterOperator); expression = this.filterValue.slice(1).reduce((root, value) => { const subTree = this.getValueOperatorNode(operator, value); return new OperatorNode(logicalOperator, root, subTree); }, this.getValueOperatorNode(operator, this.filterValue[0])); break; default: throw new Error(`unexpected value array`); } } else { if (this.filterOperator === StringOperator.Set || this.filterOperator === StringOperator.NotSet) { // no value provided for set/not set operators, and no typecast // typecasting an undefined value to string yields the string "undefined", which is always defined expression = new OperatorNode( getOperatorFromFilterOperator(this.filterOperator), new OperandNode({property: this.resourceType, value: this.propertyName}), null, ); } else { expression = this.getValueOperatorNode( getOperatorFromFilterOperator(this.filterOperator), this.filterValue, ); } } return expression; } public hasSelectedProperty(): boolean { return !!(this.resourceType && this.propertyName && this.propertyType); } public getAttributes(): Partial { const attributes = pick(this, PROPERTY_FILTER_ATTRIBUTES); if (this.propertyType === PropertyType.Datetime) { attributes.filterValue = (this.filterValue as DateRange).getAttributes(); } return attributes; } public equals(otherFilter: PropertyFilter): boolean { if ( this.resourceType !== otherFilter.resourceType || this.propertyName !== otherFilter.propertyName || this.propertyDefaultType !== otherFilter.propertyDefaultType || this.propertyType !== otherFilter.propertyType || this.filterOperator !== otherFilter.filterOperator ) { return false; } switch (this.propertyType) { case PropertyType.String: switch (this.filterOperator) { case StringOperator.Equals: case StringOperator.DoesNotEqual: const thisFilterValues = [...> this.filterValue].sort(); const otherFilterValues = [...> otherFilter.filterValue].sort(); return isEqual(thisFilterValues, otherFilterValues); default: return isEqual(this.filterValue, otherFilter.filterValue); } break; case PropertyType.Datetime: return ( this.filterValue).equals( otherFilter.filterValue); default: return isEqual(this.filterValue, otherFilter.filterValue); } return false; } public isValid(): boolean { if (!this.getPropertyTypes().includes(this.propertyType)) { return false; } if (this.propertyType !== PropertyType.Datetime && !this.getFilterOperators().includes(this.filterOperator)) { return false; } switch (this.propertyType) { case PropertyType.String: switch (this.filterOperator) { case StringOperator.Contains: case StringOperator.DoesNotContain: return !!this.filterValue; case StringOperator.Equals: case StringOperator.DoesNotEqual: return Array.isArray(this.filterValue) && this.filterValue.length > 0 && this.filterValue.every(Boolean); default: break; } break; case PropertyType.Datetime: return this.filterValue instanceof DateRange && this.filterValue.isValid(); case PropertyType.Number: switch (this.filterOperator) { case NumberOperator.Between: return Array.isArray(this.filterValue) && this.filterValue.length === 2 && this.filterValue.every(isNumber); default: return isNumber(this.filterValue); } case PropertyType.List: return !!this.filterValue; default: break; } return true; } public clonePropertyFilter(newAttributes: Partial): this { return new (this.constructor as any)({ ...this.getAttributes(), ...newAttributes, }); } public setDateRange(dateRange: DateRange): this { return this.clonePropertyFilter({ filterOperator: dateRange.type, filterValue: dateRange, }); } public setAttribute(key: keyof PropertyFilter, value: any): this { switch (key) { case 'propertyType': { return this.clonePropertyFilter({ propertyType: value, ...this.getDefaultFilterAttributes(value), }); } case 'filterOperator': { return this.clonePropertyFilter( this.getDefaultFilterAttributes({ filterOperator: value, propertyType: this.propertyType, }), ); } case 'filterValue': { return this.clonePropertyFilter({ filterValue: value, }); } default: { throw new Error(`Attribute ${key} cannot be set on PropertyFilter`); } } } public getLabel(): string { return this.getLabelParts() .map(part => part.label) .map((label, idx) => idx < 2 ? label : renamePropertyValue(label, this.propertyName)) .join(` `); } public getLabelParts(): Array { const operatorString = this.propertyType === PropertyType.Boolean ? `is` : this.filterOperator; const valueString = String(this.filterValue); let valueParts: Array; if (this.filterValue === null) { return [renameProperty(this.propertyName), operatorString].map(highlightPart); } else if (Array.isArray(this.filterValue)) { switch (this.propertyType) { case PropertyType.Number: // filter between two values const [first, second] = this.filterValue .map(String) .map(highlightPart); valueParts = [first, plainPart(`and`), second]; break; case PropertyType.String: { const stringValues = this.filterValue as Array; let valueStringParts; if (stringValues.length > MAX_LABEL_ENTRIES) { valueStringParts = stringValues .slice(0, MAX_LABEL_ENTRIES - 1) .map(highlightPart) .concat(plainPart(`${this.filterValue.length - MAX_LABEL_ENTRIES + 1} others`)); } else { valueStringParts = stringValues.map(highlightPart); } valueParts = valueStringParts.reduce((acc, part, idx, arr) => { return idx !== arr.length - 1 ? [...acc, part, plainPart(`or`)] : [...acc, part]; }, []); break; } default: throw new Error(`unexpected array value`); } } else if (this.propertyType === PropertyType.Datetime) { const filterValue = this.filterValue as DateRange; switch (this.filterOperator as DateRangeType) { case DateRangeType.Between: { const {from, to} = filterValue; const [first, second] = [from, to] .map(date => formatDate(parseDate(date))) .map(highlightPart); valueParts = [first, plainPart(`and`), second]; break; } case DateRangeType.On: case DateRangeType.Since: valueParts = [formatDate(parseDate(filterValue.from))].map(highlightPart); break; case DateRangeType.RelativeAfter: case DateRangeType.RelativeBefore: const window = filterValue.window; valueParts = [highlightPart(`${window.value > 1 ? `${window.value} ` : ``}${pluralize(window.unit, window.value)}`)]; break; default: break; } } else { valueParts = [highlightPart(valueString)]; } return [ highlightPart(renameProperty(this.propertyName)), plainPart(operatorString), ...valueParts, ]; } public getPropertyTypes(): Array { return [ PropertyType.String, PropertyType.Number, PropertyType.Boolean, PropertyType.Datetime, PropertyType.List, ]; } public getBooleanOperators(): Array { return [ BooleanOperator.True, BooleanOperator.False, ]; } public getListOperators(): Array { return [ ListOperator.Contains, ListOperator.DoesNotContain, ]; } public getNumberOperators(): Array { return [ NumberOperator.Between, NumberOperator.GreaterThan, NumberOperator.LessThan, NumberOperator.EqualTo, ]; } public getStringOperators(): Array { return [ StringOperator.Contains, StringOperator.DoesNotContain, StringOperator.Equals, StringOperator.DoesNotEqual, StringOperator.Set, StringOperator.NotSet, ]; } public getFilterOperators(): Array { switch (this.propertyType) { case PropertyType.Boolean: return this.getBooleanOperators(); case PropertyType.List: return this.getListOperators(); case PropertyType.Number: return this.getNumberOperators(); case PropertyType.String: return this.getStringOperators(); default: return null; } } private getPropertyNode(): ExpressionNode { /** * Always apply a typecast operator in the tree, even when the user did not explicitly choose a typecast. * This has the benefit of capturing the property type information in the tree, allowing us to distinguish * between different UI representations for operators such as == */ return new OperatorNode( getTypeCastOperatorFromType(this.propertyType), new OperandNode({property: this.resourceType, value: this.propertyName}), null, ); } private getValueOperatorNode(operator: Operator, value: SerializedFilterValue) { if (operator === ArithmeticOperator.In || operator === ArithmeticOperator.NotIn) { // ensure that JQL output is " in " by having value as left child return new OperatorNode( operator, new OperandNode({property: SerializedResourceType.Literal, value}), this.getPropertyNode(), ); } else { return new OperatorNode( operator, this.getPropertyNode(), new OperandNode({property: SerializedResourceType.Literal, value}), ); } } private getBetweenOperatorExpressionTree(greaterThan: SerializedFilterOperand, lessThan: SerializedFilterOperand) { return new OperatorNode( LogicalOperator.And, new OperatorNode( ArithmeticOperator.GreaterThanEquals, this.getPropertyNode(), new OperandNode({property: SerializedResourceType.Literal, value: greaterThan}), ), new OperatorNode( ArithmeticOperator.LessThanEquals, this.getPropertyNode(), new OperandNode({property: SerializedResourceType.Literal, value: lessThan}), ), ); } private getDefaultFilterAttributes( {propertyType, filterOperator}: {propertyType: PropertyType, filterOperator?: FilterOperator}, ): {filterOperator: FilterOperator, filterValue: FilterValue} { if (!filterOperator && DEFAULT_FILTER_OPERATOR.hasOwnProperty(propertyType)) { filterOperator = DEFAULT_FILTER_OPERATOR[propertyType]; } let filterValue: FilterValue; switch (propertyType) { case PropertyType.Boolean: { switch (filterOperator as BooleanOperator) { case BooleanOperator.True: { filterValue = true; break; } case BooleanOperator.False: { filterValue = false; break; } default: break; } break; } case PropertyType.Datetime: { filterValue = this.createDateRange(); filterOperator = filterValue.type; break; } case PropertyType.List: { filterValue = null; break; } case PropertyType.Number: { switch (filterOperator as NumberOperator) { case NumberOperator.Between: { filterValue = [null, null]; break; } default: { filterValue = null; } } break; } case PropertyType.String: { switch (filterOperator as StringOperator) { case StringOperator.Equals: case StringOperator.DoesNotEqual: { filterValue = []; break; } default: { filterValue = null; } } break; } default: break; } return {filterOperator, filterValue}; } }