Skip to content

Commit

Permalink
Implement and test "universal dropdown" for semantic search (#455)
Browse files Browse the repository at this point in the history
  • Loading branch information
jgonggrijp committed May 11, 2021
1 parent f1c72dc commit a7a8a3d
Show file tree
Hide file tree
Showing 3 changed files with 277 additions and 0 deletions.
80 changes: 80 additions & 0 deletions frontend/src/semantic-search/dropdown-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { t } from 'i18next';

import Collection from '../core/collection';
import { xsd } from '../common-rdf/ns';

export const logic = new Collection([{
id: 'logic:and',
label: t('filters.and', 'AND'),
}, {
id: 'logic:or',
label: t('filters.or', 'OR'),
}, {
id: 'logic:not',
label: t('filters.not', 'NOT'),
}]);

export const filters = new Collection([{
id: 'filter:equals',
label: t('filters.equals', 'Is exactly'),
uris: true,
literals: true,
}, {
id: 'filter:less',
label: t('filters.less', 'Is less than'),
uris: false,
literals: true,
}, {
id: 'filter:greater',
label: t('filters.greater', 'Is greater than'),
uris: false,
literals: true,
}, {
id: 'filter:isIRI',
label: t('filters.isDefined', 'Is defined'),
uris: true,
literals: false,
}, {
id: 'filter:isLiteral',
label: t('filters.isDefined', 'Is defined'),
uris: false,
literals: true,
}, {
id: 'filter:stringStarts',
label: t('filters.startsWith', 'Starts with'),
uris: false,
literals: true,
restrict: [xsd.string],
}, {
id: 'filter:stringEnds',
label: t('filters.endsWith', 'Ends with'),
uris: false,
literals: true,
restrict: [xsd.string],
}, {
id: 'filter:stringContains',
label: t('filters.contains', 'Contains'),
uris: false,
literals: true,
restrict: [xsd.string],
}, {
id: 'filter:regex',
label: t('filters.matchRegex', 'Matches regular expression'),
uris: false,
literals: true,
restrict: [xsd.string],
}]);

export const groupLabels = new Collection([{
id: 'logic',
label: t('filters.groupLogic', 'apply logic'),
}, {
id: 'filter',
label: t('filters.groupFilters', 'apply filter'),
}, {
id: 'type',
label: t('filters.groupType', 'expect type'),
}, {
id: 'predicate',
label: t('filters.groupPredicates', 'traverse predicate'),
}]);
31 changes: 31 additions & 0 deletions frontend/src/semantic-search/dropdown-view-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { constant } from 'lodash';

import { enableI18n, startStore, endStore } from '../test-util';
import mockOntology from '../mock-data/mock-ontology';

import Model from '../core/model';
import ldChannel from '../common-rdf/radio';
import Graph from '../common-rdf/graph';

import Dropdown from './dropdown-view';

describe('semantic search DropdownView', function() {
beforeAll(enableI18n);
beforeEach(startStore);

beforeEach(function() {
const ontology = new Graph(mockOntology);
ldChannel.reply('ontology:graph', constant(ontology));
});

afterEach(function() {
ldChannel.stopReplying('ontology:graph');
});

afterEach(endStore);

it('can be constructed in isolation', function() {
const view = new Dropdown();
expect(view.$('select optgroup').length).toBe(2);
});
});
166 changes: 166 additions & 0 deletions frontend/src/semantic-search/dropdown-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { extend, includes, debounce, chain, each, propertyOf } from 'lodash';
import { SubViewDescription } from 'backbone-fractal/dist/composite-view';
import 'select2';

import Model from '../core/model';
import View, { CompositeView, CollectionView } from '../core/view';
import { xsd, rdfs } from '../common-rdf/ns';
import ldChannel from '../common-rdf/radio';
import Node from '../common-rdf/node';
import Graph from '../common-rdf/graph';
import FilteredCollection from '../common-adapters/filtered-collection';
import FlatCollection from '../common-adapters/flat-item-collection';
import BasePicker from '../forms/base-picker-view';
import Select2Picker from '../forms/select2-picker-view';
import {
isRdfsClass,
getRdfSubClasses ,
} from '../utilities/linked-data-utilities';
import { applicablePredicates } from '../utilities/relation-utilities';

import { logic, filters, groupLabels } from './dropdown-constants';

/**
* Generate a filter predicate for selecting filters that apply to a given
* range. Meant to use with a `FilteredCollection` layered on top of the
* `filters` constant.
*/
function applicableTo(range: string): (Model) => boolean {
const isLiteral = range.startsWith(xsd());
return (model) => {
const { uris, literals, restrict } = model.attributes;
if (restrict) return includes(restrict, range);
return isLiteral ? literals : uris;
}
}

function normalizeRange(model: Model): Graph {
let range;
const precedent = model.get('precedent');
if (precedent) range = precedent.get(rdfs.range) || [precedent];
if (!range) range = ldChannel.request('ontology:graph').filter(isRdfsClass);
range = getRdfSubClasses(range);
const rangeGraph = new Graph(range)
each(range, cls => {
const superClasses = cls.get(rdfs.subClassOf);
each(superClasses, rangeGraph.remove.bind(rangeGraph));
});
return rangeGraph;
}

class Option extends View {
initialize(): void {
this.render().listenTo(this.model, 'change', this.render);
}
render(): this {
const label = this.model.get('label') || this.model.get('classLabel');
this.$el.prop('value', this.model.id).text(label);
return this;
}
}
extend(Option.prototype, {
tagName: 'option',
});

class OptionGroup extends CollectionView {
initialize(): void {
this.initItems().render().initCollectionEvents();
}
renderContainer(): this {
this.$el.prop('label', this.model.get('label'));
return this;
}
}
extend(OptionGroup.prototype, {
tagName: 'optgroup',
subview: Option,
});

export default class Dropdown extends CompositeView {
logicGroup: View;
filterGroup: View;
typeGroup: View;
predicateGroup: View;
groupOrder: Array<keyof Dropdown>;
val: BasePicker['val'];

initialize(): void {
this.model = this.model || new Model();
this.restoreSelection = debounce(this.restoreSelection, 50);
this.logicGroup = new OptionGroup({
model: groupLabels.get('logic'),
collection: logic,
});
let range: Graph | Node = normalizeRange(this.model);
if (range.length > 1) {
this.typeGroup = new OptionGroup({
model: groupLabels.get('type'),
collection: new FlatCollection(range),
});
} else {
range = range.at(0);
const criterion = applicableTo(range.id);
this.filterGroup = new OptionGroup({
model: groupLabels.get('filter'),
collection: new FilteredCollection(filters, criterion),
});
const predicates = applicablePredicates(range);
this.listenTo(predicates, 'update', this.restoreSelection);
this.predicateGroup = new OptionGroup({
model: groupLabels.get('predicate'),
collection: new FlatCollection(predicates),
});
}
this.render();
}

subviews(): SubViewDescription[] {
return chain(this.groupOrder)
.map(propertyOf(this))
.compact()
.map(view => ({ selector: 'select', view }))
.value();
}

placeSubviews(): this {
super.placeSubviews();
return this.restoreSelection();
}

renderContainer(): this {
this.$el.append('<select>');
return this;
}

remove(): this {
this.$('select').select2('destroy');
return super.remove();
}

restoreSelection(): this {
const selection = this.model.get('selection');
this.val(selection && selection.id);
return this;
}

forwardChange(event): void {
const id = this.val() as string;
const scheme = id.split(':')[0];
const model = (
scheme === 'logic' ? logic.get(id) :
scheme === 'filter' ? filters.get(id) :
ldChannel.request('obtain', id)
);
this.model.set('selection', model);
this.trigger('change', this, model, event);
}
}

extend(Dropdown.prototype, {
className: 'select readit-picker',
groupOrder: ['logicGroup', 'typeGroup', 'filterGroup', 'predicateGroup'],
events: { change: 'forwardChange' },
val: BasePicker.prototype.val,
beforeRender: Select2Picker.prototype.beforeRender,
afterRender: Select2Picker.prototype.afterRender,
});

0 comments on commit a7a8a3d

Please sign in to comment.