diff --git a/package.json b/package.json index 35407b5d7..aa008dabc 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "ts-loader": "4.0.1", "tslib": "~1.11.0", "tslint": "5.9.1", - "ts-jest": "^25.2.1", + "ts-jest": "25.2.1", "typedoc": "~0.16.10", "typescript": "~3.8.2", "url-loader": "0.5.8", diff --git a/src/lineup/internal/OverviewColumn.ts b/src/lineup/internal/OverviewColumn.ts index 8c7aa7f38..059407304 100644 --- a/src/lineup/internal/OverviewColumn.ts +++ b/src/lineup/internal/OverviewColumn.ts @@ -16,12 +16,12 @@ export default class OverviewColumn extends BooleanColumn { constructor(id: string, desc: IBooleanColumnDesc) { super(id, Object.assign(desc, { - label: i18n.t('tdp:core.lineup.OverviewColumn.overviewSelection') + label: i18n.t('tdp:core.lineup.OverviewColumn.overviewSelection'), + renderer: 'boolean', + groupRenderer: 'boolean', + summaryRenderer: 'categorical' })); - (this).setDefaultRenderer('boolean'); - (this).setDefaultGroupRenderer('boolean'); - (this).setDefaultSummaryRenderer('categorical'); - (this).setWidthImpl(0); // hide + this.setWidthImpl(0); // hide } getValue(row: IDataRow) { diff --git a/src/lineup/internal/cmds.ts b/src/lineup/internal/cmds.ts index 47ea2ecd9..325c6d6f7 100644 --- a/src/lineup/internal/cmds.ts +++ b/src/lineup/internal/cmds.ts @@ -7,6 +7,7 @@ import {IObjectRef, action, meta, cat, op, ProvenanceGraph, ICmdResult} from 'ph import {NumberColumn, LocalDataProvider, StackColumn, ScriptColumn, OrdinalColumn, CompositeColumn, Ranking, ISortCriteria, Column, isMapAbleColumn, mappingFunctions} from 'lineupjs'; import {resolveImmediately} from 'phovea_core/src'; import i18n from 'phovea_core/src/i18n'; +import {isSerializedFilter, restoreLineUpFilter, serializeLineUpFilter, isLineUpStringFilter} from './cmds/filter'; // used for function calls in the context of tracking or untracking actions in the provenance graph in order to get a consistent defintion of the used strings @@ -220,9 +221,15 @@ export async function setColumnImpl(inputs: IObjectRef[], parameter: any) { bak = source[`getRenderer`](); source[`setRenderer`].call(source, parameter.value); break; + case LineUpTrackAndUntrackActions.filter: + bak = source[`get${prop}`](); + // restore serialized regular expression before passing to LineUp + const value = isSerializedFilter(parameter.value) ? restoreLineUpFilter(parameter.value) : parameter.value; + source[`set${prop}`].call(source, value); + break; default: bak = source[`get${prop}`](); - source[`set${prop}`].call(source, restoreRegExp(parameter.value)); // restore serialized regular expression before passing to LineUp + source[`set${prop}`].call(source, parameter.value); break; } } @@ -349,7 +356,9 @@ function recordPropertyChange(source: Column | Ranking, provider: LocalDataProvi return; } - const newSerializedValue = serializeRegExp(newValue); // serialize possible RegExp object to be properly stored as provenance graph + if (property === LineUpTrackAndUntrackActions.filter) { + newValue = isLineUpStringFilter(newValue) ? serializeLineUpFilter(newValue) : newValue; // serialize possible RegExp object to be properly stored as provenance graph + } if (source instanceof Column) { // assert ALineUpView and update the stats @@ -357,12 +366,12 @@ function recordPropertyChange(source: Column | Ranking, provider: LocalDataProvi const rid = rankingId(provider, source.findMyRanker()); const path = source.fqpath; - graph.pushWithResult(setColumn(lineupViewWrapper, rid, path, property, newSerializedValue), { + graph.pushWithResult(setColumn(lineupViewWrapper, rid, path, property, newValue), { inverse: setColumn(lineupViewWrapper, rid, path, property, old) }); } else if (source instanceof Ranking) { const rid = rankingId(provider, source); - graph.pushWithResult(setColumn(lineupViewWrapper, rid, null, property, newSerializedValue), { + graph.pushWithResult(setColumn(lineupViewWrapper, rid, null, property, newValue), { inverse: setColumn(lineupViewWrapper, rid, null, property, old) }); } @@ -370,59 +379,6 @@ function recordPropertyChange(source: Column | Ranking, provider: LocalDataProvi source.on(`${property}Changed.track`, delayed > 0 ? delayedCall(f, delayed) : f); } -/** - * Serialize RegExp objects from LineUp string columns as plain object - * that can be stored in the provenance graph - */ -interface IRegExpFilter { - /** - * RegExp as string - */ - value: string; - /** - * Flag to indicate the value should be restored as RegExp - */ - isRegExp: boolean; -} - -/** - * Serializes RegExp objects to an IRegexFilter object, which can be stored in the provenance graph. - * In case a string is passed to this function no serialization is applied. - * - * Background information: - * The serialization step is necessary, because RegExp objects are converted into an empty object `{}` on `JSON.stringify`. - * ``` - * JSON.stringify(/^123$/gm); // result: {} - * ``` - * - * @param value Input string or RegExp object - * @returns {string | IRegExpFilter} Returns the input string or a plain `IRegExpFilter` object - */ -function serializeRegExp(value: string | RegExp): string | IRegExpFilter { - if (!(value instanceof RegExp)) { - return value; - } - return {value: value.toString(), isRegExp: true}; -} - -/** - * Restores a RegExp object from a given IRegExpFilter object. - * In case a string is passed to this function no deserialization is applied. - * - * @param filter Filter as string or plain object matching the IRegExpFilter - * @returns {string | RegExp| null} Returns the input string or the restored RegExp object - */ -function restoreRegExp(filter: string | IRegExpFilter): string | RegExp { - if (filter === null || !(filter).isRegExp) { - return filter; - } - - const serializedRegexParser = /^\/(.+)\/(\w+)?$/; // from https://gist.github.com/tenbits/ec7f0155b57b2d61a6cc90ef3d5f8b49 - const matches = serializedRegexParser.exec((filter).value); - const [_full, regexString, regexFlags] = matches; - return new RegExp(regexString, regexFlags); -} - function trackColumn(provider: LocalDataProvider, lineup: IObjectRef, graph: ProvenanceGraph, col: Column) { recordPropertyChange(col, provider, lineup, graph, LineUpTrackAndUntrackActions.metaData); recordPropertyChange(col, provider, lineup, graph, LineUpTrackAndUntrackActions.filter); diff --git a/src/lineup/internal/cmds/filter.ts b/src/lineup/internal/cmds/filter.ts new file mode 100644 index 000000000..400855c8f --- /dev/null +++ b/src/lineup/internal/cmds/filter.ts @@ -0,0 +1,175 @@ +/** + * Basic LineUp string filter values + */ +type LineUpStringFilterValue = string[] | string | null; + +/** + * This type guard checks if `filter` parameter matches the `LineUpStringFilterValue` type + * @param filter Any filterable value that should be checked + * @returns Returns true if filter applies to the `LineUpStringFilterValue` + */ +function isLineUpStringFilterValue(filter: any): filter is LineUpStringFilterValue { + return filter === null || typeof filter === 'string' || Array.isArray(filter); +} + +/** + * Serialize RegExp objects from LineUp string columns as plain object + * that can be stored in the provenance graph + */ +interface IRegExpFilter { + /** + * RegExp as string + */ + value: LineUpStringFilterValue; + /** + * Flag to indicate the value should be restored as RegExp + */ + isRegExp: boolean; +} + +/** + * This type guard checks if `filter` parameter matches the `IRegExpFilter` type. + * @param filter Any filterable value that should be checked + * @returns Returns true if filter applies to the `IRegExpFilter` + */ +function isIRegExpFilter(filter: any): filter is IRegExpFilter { + return filter && filter.hasOwnProperty('value') && isLineUpStringFilterValue(filter.value) && filter.hasOwnProperty('isRegExp'); +} + +/** + * This interface combines the `IStringFilter` from `StringColumn` + * and `ICategoricalFilter` from `ICategoricalColumn`. + */ +interface ILineUpStringFilter { + /** + * Filter value + */ + filter: LineUpStringFilterValue | RegExp; + + /** + * Filter for missing values + */ + filterMissing: boolean; +} + +/** + * This type guard checks if the `filter` parameter matches the `isLineUpStringFilter` type. + * + * @internal + * @param filter Any value that could be a filter + * @returns Returns true if filter should be serialized/restored or false if not. + */ +export function isLineUpStringFilter(filter: any): filter is ILineUpStringFilter { + return filter && filter.hasOwnProperty('filter') && (isLineUpStringFilterValue(filter.filter) || filter.filter instanceof RegExp) && filter.hasOwnProperty('filterMissing'); +} + + +/** + * Similar to the `ILineUpStringFilter`, but the RegExp is replaced with `IRegExpFilter` + */ +interface ISerializableLineUpFilter { + /** + * Filter value + * Note that the RegExp is replaced with IRegExpFilter (compared to the `ILineUpStringFilter` interface) + */ + filter: LineUpStringFilterValue | IRegExpFilter; + + /** + * Filter for missing values + */ + filterMissing: boolean; +} + +/** + * This type guard checks if the `filter` parameter matches the `ISerializableLineUpFilter` type. + * Necessary since number columns filter has properties `min`, `max` and no filter property. + * + * @internal + * @param filter Any value that could be a filter + * @returns Returns true if filter should be serialized/restored or false if not. + */ +export function isSerializedFilter(filter: any): filter is ISerializableLineUpFilter { + return filter && filter.hasOwnProperty('filter') && (isLineUpStringFilterValue(filter.filter) || isIRegExpFilter(filter.filter)) && filter.hasOwnProperty('filterMissing'); +} + +/** + * Serializes LineUp string filter, which can contain RegExp objects to an IRegexFilter object. + * The return value of this function can be passed to `JSON.stringify()` and stored in the provenance graph. + * + * Background information: + * The serialization step is necessary, because RegExp objects are converted into an empty object `{}` on `JSON.stringify`. + * ``` + * JSON.stringify(/^123$/gm); // result: {} + * ``` + * + * @internal + * + * @param filter LineUp filter object + * @returns Returns the `ISerializableLineUpFilter` object + */ +export function serializeLineUpFilter(filter: ILineUpStringFilter): ISerializableLineUpFilter { + const value = filter.filter; + const isRegExp = value instanceof RegExp; + return { + filter: { + value: isRegExp ? value.toString() : value as LineUpStringFilterValue, + isRegExp + }, + filterMissing: filter.filterMissing + }; +} + +/** + * Coverts a RegExp string to a RegExp instance + * + * @internal + * @param value RegExp string + * @returns The RegExp instance + */ +export function restoreRegExp(value: string): RegExp { + const serializedRegexParser = /^\/(.+)\/(\w+)?$/; // from https://gist.github.com/tenbits/ec7f0155b57b2d61a6cc90ef3d5f8b49 + const matches = serializedRegexParser.exec(value); + + if(matches === null) { + throw new Error('Unable to parse regular expression from string. The string does not seem to be a valid RegExp.'); + } + + const [_full, regexString, regexFlags] = matches; + return new RegExp(regexString, regexFlags); +} + +/** + * Restores filter values from the provenance graph and returns an `ILineUpStringFilter` + * which can be passed to the LineUp instance (using LineUp > 4.0.0). + * + * Valid seralized filter values are: + * - `LineUpStringFilterValue` used with LineUp < 4.0.0 and tdp_core < 9.0.0 + * - `IRegExpFilter` used with LineUp < 4.0.0 and tdp_core >= 9.0.0 + * - `ISerializableLineUpFilter` used with LineUp > 4.0.0 and tdp_core > 9.1.0 + * + * @interal + * @param filter Filter with one of the types described above + * @param filterMissing The flag indicates if missing values should be filtered (default = `false`) + * @returns Returns an `ILineUpStringFilter` which can be passed to LineUp + */ +export function restoreLineUpFilter(filter: LineUpStringFilterValue | IRegExpFilter | ISerializableLineUpFilter, filterMissing = false): ILineUpStringFilter { + if (isLineUpStringFilterValue(filter)) { + return {filter, filterMissing}; + + } else if (isIRegExpFilter(filter) && filter.isRegExp === true) { + if(typeof filter.value === 'string') { + return {filter: restoreRegExp(filter.value), filterMissing}; + } + + throw new Error('Wrong type of filter value. Unable to restore RegExp instance from the given filter value.'); + + } else if (isIRegExpFilter(filter) && filter.isRegExp === false) { + return restoreLineUpFilter(filter.value, filterMissing); + + } else if (isSerializedFilter(filter)) { + return restoreLineUpFilter(filter.filter, filter.filterMissing); + + } + + throw new Error('Unknown LineUp filter format. Unable to restore the given filter.'); +} diff --git a/tests/lineup/internal/cmds/filter.test.ts b/tests/lineup/internal/cmds/filter.test.ts new file mode 100644 index 000000000..5154be53f --- /dev/null +++ b/tests/lineup/internal/cmds/filter.test.ts @@ -0,0 +1,139 @@ +/// +import {serializeLineUpFilter, restoreLineUpFilter, isSerializedFilter, isLineUpStringFilter, restoreRegExp} from '../../../../src/lineup/internal/cmds/filter'; + +// The following tests worked with LineUp v3. +// With LineUp v4 the filter object changed +// describe('Serialize LineUp v3 filter for provenance graph', () => { +// it('filter as string', () => { +// expect(serializeRegExp('abc')).toBe('abc'); +// expect(typeof serializeRegExp('abc')).toBe('string'); +// }); + +// it('filter as RegExp', () => { +// expect(serializeRegExp(/abc/gm)).toMatchObject({value: '/abc/gm', isRegExp: true}); +// expect(serializeRegExp(/abc/gm)).not.toMatchObject({value: '/12345/gm', isRegExp: true}); +// expect(serializeRegExp(/abc/gm)).not.toMatchObject({value: '/abc/gm', isRegExp: false}); +// }); +// }); + +describe('Type guard isLineUpStringFilter', () => { + it('isLineUpStringFilter with string', () => { + expect(isLineUpStringFilter({filter: 'abc', filterMissing: false})).toBe(true); + }); + + it('isLineUpStringFilter with string[]', () => { + expect(isLineUpStringFilter({filter: ['abc', 'def'], filterMissing: false})).toBe(true); + }); + + it('isLineUpStringFilter with null', () => { + expect(isLineUpStringFilter({filter: null, filterMissing: false})).toBe(true); + }); + + it('isLineUpStringFilter with RegExp', () => { + expect(isLineUpStringFilter({filter: /abc/gm, filterMissing: false})).toBe(true); + }); +}); + +describe('Type guard isSerializedFilter', () => { + it('isSerializedFilter with string', () => { + expect(isSerializedFilter({filter: {value: 'abc', isRegExp: false}, filterMissing: false})).toBe(true); + }); + + it('isSerializedFilter with string[]', () => { + expect(isSerializedFilter({filter: {value: ['abc', 'def'], isRegExp: false}, filterMissing: false})).toBe(true); + }); + + it('isSerializedFilter with null', () => { + expect(isSerializedFilter({filter: {value: null, isRegExp: false}, filterMissing: false})).toBe(true); + }); + + it('isSerializedFilter with RegExp', () => { + expect(isSerializedFilter({filter: {value: '/abc/gm', isRegExp: true}, filterMissing: false})).toBe(true); + }); +}); + +describe('Restore RegExp from string', () => { + it('valid RegExp string', () => { + expect(restoreRegExp('/abc/gm').toString()).toBe('/abc/gm'); + expect(restoreRegExp('/def/gm').toString()).not.toBe('/abc/gm'); + }); + + it('invalid RegExp string', () => { + expect(() => { + restoreRegExp('invalid regexp string').toString(); + }).toThrow(new Error('Unable to parse regular expression from string. The string does not seem to be a valid RegExp.')); + }); +}); + +describe('Serialize LineUp v4 filter for provenance graph', () => { + it('filter as string', () => { + const lineUpFilter = {filter: 'abc', filterMissing: false}; + + expect(serializeLineUpFilter(lineUpFilter)).toMatchObject({filter: {value: 'abc', isRegExp: false}, filterMissing: false}); + }); + + it('filter as RegExp', () => { + const lineUpFilter = {filter: /abc/gm, filterMissing: false}; + + expect(serializeLineUpFilter(lineUpFilter)).toMatchObject({filter: {value: '/abc/gm', isRegExp: true}, filterMissing: false}); + expect(serializeLineUpFilter(lineUpFilter)).not.toMatchObject({filter: {value: '/12345/gm', isRegExp: true}, filterMissing: false}); + expect(serializeLineUpFilter(lineUpFilter)).not.toMatchObject({filter: {value: '/abc/gm', isRegExp: false}, filterMissing: false}); + }); + + it('filter as array', () => { + const lineUpFilter = {filter: ['chromosome', 'gender'], filterMissing: false}; + expect(serializeLineUpFilter(lineUpFilter)).toMatchObject({filter: {value: ['chromosome', 'gender'], isRegExp: false}, filterMissing: false}); + }); + + it('filter as null and use regular expression true', () => { + const lineUpFilter = {filter: null, filterMissing: true}; + expect(serializeLineUpFilter(lineUpFilter)).toMatchObject({filter: {value: null, isRegExp: false}, filterMissing: true}); + }); +}); + + +describe('Restore LineUp filter from provenance graph', () => { + it('filter as string', () => { + expect(restoreLineUpFilter('abc')).toMatchObject({filter: 'abc', filterMissing: false}); + expect(restoreLineUpFilter('abc').hasOwnProperty('filterMissing')).toBeTruthy(); + }); + + it('filter as null and `filterMissing: true`', () => { + const fromGraphFilter = {filter: {value: null, isRegExp: false}, filterMissing: true}; + expect(restoreLineUpFilter(fromGraphFilter)).toMatchObject({filter: null, filterMissing: true}); + }); + + it('filter as IRegExpFilter with `value: abc`, `isRegexp: false`', () => { + expect(restoreLineUpFilter({value: 'abc', isRegExp: false})).toMatchObject({filter: 'abc', filterMissing: false}); + }); + + it('filter as IRegExpFilter with `value: null`, `isRegexp: false`', () => { + expect(restoreLineUpFilter({value: null, isRegExp: false})).toMatchObject({filter: null, filterMissing: false}); + }); + + it('filter as IRegExpFilter with `value: /^abc/gm`, `isRegexp: true`', () => { + expect(restoreLineUpFilter({value: '/^abc/gm', isRegExp: true})).toMatchObject({filter: /^abc/gm, filterMissing: false}); + }); + + it('filter as ISerializableLineUpFilter', () => { + const fromGraphFilter = {filter: {value: '/abc$/gm', isRegExp: true}, filterMissing: false}; + expect(restoreLineUpFilter(fromGraphFilter)).toMatchObject({filter: /abc$/gm, filterMissing: false}); + }); + + it('filter as ISerializableLineUpFilter with property `filterMissing: true`', () => { + const fromGraphFilter = {filter: {value: '/abc$/gm', isRegExp: true}, filterMissing: true}; + expect(restoreLineUpFilter(fromGraphFilter)).toMatchObject({filter: /abc$/gm, filterMissing: true}); + }); + + it('filter as ISerializableLineUpFilter with property `value: null`', () => { + const fromGraphFilter = {filter: {value: null, isRegExp: false}, filterMissing: true}; + expect(restoreLineUpFilter(fromGraphFilter)).toMatchObject({filter: null, filterMissing: true}); + }); + + it('unknown filter format throws error', () => { + expect(() => { + restoreLineUpFilter(123456789); // typecast to pass unknown format + }).toThrowError(new Error('Unknown LineUp filter format. Unable to restore the given filter.')); + }); +}); +