diff --git a/assets/src/bundles/browse/origin-search.js b/assets/src/bundles/browse/origin-search.js --- a/assets/src/bundles/browse/origin-search.js +++ b/assets/src/bundles/browse/origin-search.js @@ -6,7 +6,8 @@ */ import {handleFetchError, isArchivedOrigin} from 'utils/functions'; - +import {Autocomplete} from 'utils/autocomplete'; +// import {initAutocomplete} from 'utils/search-ql-autocomplete'; const limit = 100; const linksPrev = []; let linkNext = null; @@ -193,6 +194,12 @@ export function initOriginSearch() { $(document).ready(() => { + // initAutocomplete(document.querySelector("#swh-origins-url-patterns")) + // const autocomplete = + new Autocomplete({ + inputBox: document.querySelector('#swh-origins-url-patterns'), + suggestions: ['a', 'aa', 'aaa', 'aab'] + }); $('#swh-search-origins').submit(event => { event.preventDefault(); if (event.target.checkValidity()) { diff --git a/assets/src/utils/autocomplete.css b/assets/src/utils/autocomplete.css new file mode 100644 --- /dev/null +++ b/assets/src/utils/autocomplete.css @@ -0,0 +1,46 @@ +/* the container must be positioned relative: */ +.autocomplete { + position: relative; + display: inline-block; +} + +input.invalid { + outline: none !important; + border: 1px solid red; +} + +input[type=submit] { + background-color: dodgerblue; + color: #fff; + cursor: pointer; +} + +/* position the autocomplete items to be the same width as the container: */ +.autocomplete-items { + position: absolute; + border: 1px solid #d4d4d4; + border-bottom: none; + border-top: none; + z-index: 99999; + top: 100%; + left: 0; + right: 0; +} + +.autocomplete-items div { + padding: 10px; + cursor: pointer; + background-color: #fff; + border-bottom: 1px solid #d4d4d4; +} + +/* when hovering an item: */ +.autocomplete-items div:hover { + background-color: #e9e9e9; +} + +/* when navigating through the items using the arrow keys: */ +.autocomplete-active { + background-color: dodgerblue !important; + color: #fff; +} diff --git a/assets/src/utils/autocomplete.js b/assets/src/utils/autocomplete.js new file mode 100644 --- /dev/null +++ b/assets/src/utils/autocomplete.js @@ -0,0 +1,92 @@ +import 'utils/autocomplete.css'; + +export class Autocomplete { + constructor(params) { + const {inputBox, suggestions} = params; + this.inputBox = inputBox; + this.suggestions = suggestions; + this.currentIndex = -1; + + this.autocompleteList = document.createElement('div'); + this.autocompleteList.setAttribute('class', 'autocomplete-items'); + this.inputBox.parentNode.appendChild(this.autocompleteList); + + this.initListeners(); + } + + initListeners() { + this.inputBox.addEventListener('focus', this.updateLists.bind(this)); + this.inputBox.addEventListener('input', this.updateLists.bind(this)); + + this.inputBox.addEventListener('keydown', (e) => { + if (e.keyCode === 40) { // down + this.currentIndex++; + this.addActive(this.autocompleteList); + } else if (e.keyCode === 38) { // up + this.currentIndex--; + this.addActive(this.autocompleteList); + } else if (e.keyCode === 13) { // enter + e.preventDefault(); + if (this.currentIndex > -1) { + // Simulate a click on the "active" item: + if (this.autocompleteList) this.autocompleteList.children[this.currentIndex].click(); + } + } + }); + + document.addEventListener('click', (e) => { this.removeAllItems(e.target); }); + } + + updateLists() { + const inputValue = this.inputBox.value; + + /* close any already open lists of autocompleted values */ + this.removeAllItems(); + + this.currentFocusIdx = -1; + + this.suggestions.forEach(suggestion => { + const itemDiv = document.createElement('div'); + itemDiv.innerHTML = `${suggestion.substr(0, inputValue.length)}`; + itemDiv.innerHTML += suggestion.substr(inputValue.length); + itemDiv.setAttribute('data-value', suggestion); + + const suggestionClick = (e) => { + this.inputBox.value += e.target.getAttribute('data-value'); + this.inputBox.focus(); + }; + + itemDiv.addEventListener('click', suggestionClick.bind(this)); + + this.autocompleteList.appendChild(itemDiv); + }); + } + + addActive() { + // a function to classify an item as "active": + if (!this.autocompleteList) return false; + // start by removing the "active" class on all items: + const n = this.autocompleteList.childElementCount; + this.removeActive(); + if (this.currentIndex >= n) this.currentIndex = 0; + if (this.currentIndex < 0) this.currentIndex = (n - 1); + // add class "autocomplete-active": + this.autocompleteList.children[this.currentIndex].classList.add('autocomplete-active'); + } + + removeActive() { + /* a function to remove the "active" class from all autocomplete items */ + Array.from(this.autocompleteList.children).forEach(autocompleteItem => { + autocompleteItem.classList.remove('autocomplete-active'); + }); + } + + removeAllItems(element) { + /* + close all autocomplete lists in the document, + except the one passed as an argument + */ + if (element !== this.inputBox && this.autocompleteList) { this.autocompleteList.innerHTML = ''; } + } + +} diff --git a/assets/src/utils/search-ql-autocomplete.js b/assets/src/utils/search-ql-autocomplete.js new file mode 100644 --- /dev/null +++ b/assets/src/utils/search-ql-autocomplete.js @@ -0,0 +1,155 @@ +// import { Parser } from 'web-tree-sitter' + +import {Autocomplete} from 'utils/autocomplete.js'; + +const filterNames = [ + 'origin', 'metadata', // pattern + 'visited', // boolean + 'visits', // numeric + 'visit_type', // boundedList + 'language', 'license', 'keyword', // unboundedList + 'last_visit', 'last_eventful_visit', 'last_revision', 'last_release', 'created', 'modified', 'published', // date + 'sort_by', + 'limit' +]; + +/* eslint-disable */ +const sortByOptions = ['visits', + 'last_visit', + 'last_eventful_visit', + 'last_revision', + 'last_release', + 'created', + 'modified', + 'published' +]; + +const visitTypeOptions = [ + 'any', + 'cran', + 'deb', + 'deposit', + 'ftp', + 'hg', + 'git', + 'nixguix', + 'npm', + 'pypi', + 'svn', + 'tar' +]; + + +const equalOp = ['=']; +const rangeOp = ['<', '<=', '=', '!=', '>=', '>']; +const listOp = ['in', 'not in']; + +const filterFieldToOperatorMap = { + 'patternField': equalOp, + 'booleanField': equalOp, + 'numericField': rangeOp, + 'listField': listOp, + 'sortByField': equalOp, + 'dateField': rangeOp +}; + +const filterOperators = ['equalOp', 'listOp', 'rangeOp']; + +const filterValues = ['singleWord', 'number', 'isoDateTime']; + +const filterCategories = ['patternFilter', 'booleanFilter', 'numericFilter', 'boundedListFilter', 'unboundedListFilter', 'dateFilter']; + +/* eslint-enable */ + +const findMissingNode = (node) => { + if (node.isMissing()) { + return node; + } + if (node.children.length > 0) { + for (let i = 0; i < node.children.length; i++) { + const missingNode = findMissingNode(node.children[i]); + if (missingNode !== null) { return missingNode; } + } + } + + return null; +}; + +const isWrapperNode = (child, parent) => { + if (!child || !parent) return false; + if (parent.namedChildren.length === 1 && parent.type !== 'ERROR') return true; + return ( + (child.startPosition.column === parent.startPosition.column) && + (child.endPosition.column === parent.endPosition.column) + ); +}; + +const suggestNextNode = (tree, inputBox) => { + + const cursor = inputBox.selectionStart - 1; + + const query = inputBox.value; + + const lastNodeTypeDiv = document.getElementById('last-node-type'); + + let lastTokenIndex = cursor; + while (query[lastTokenIndex] === ' ') { lastTokenIndex--; } + + const lastTokenPosition = {row: 0, column: lastTokenIndex}; + const lastTokenNode = tree.rootNode.descendantForPosition(lastTokenPosition, lastTokenPosition); + lastNodeTypeDiv.innerText = lastTokenNode.type; + + const missingNode = findMissingNode(tree.rootNode); + if (missingNode && missingNode !== null) { + if (missingNode.type === ')') { + return [')']; + } else if (missingNode.type === ']') { + return [']']; + } + } + + let ancestorOfLastTokenNode = lastTokenNode; + while (isWrapperNode(ancestorOfLastTokenNode, ancestorOfLastTokenNode.parent)) { + ancestorOfLastTokenNode = ancestorOfLastTokenNode.parent; + } + + if (lastTokenNode.type === 'ERROR' || (lastTokenNode.type === '(') || (lastTokenNode.type === 'and' || lastTokenNode.type === 'or')) { + return ['('].concat(filterNames); + } else if (lastTokenNode.type in filterFieldToOperatorMap) { return filterFieldToOperatorMap[lastTokenNode.type]; } else if (filterOperators.indexOf(lastTokenNode.type) >= 0) { return ['Value']; } else if ((!tree.rootNode.hasError() && (ancestorOfLastTokenNode.type.indexOf('Val') > 0)) || lastTokenNode.type === ')') { + return ['and', 'or']; + } + + return []; +}; + +/* eslint-disable */ +export const initAutocomplete = (inputBox) => { + Parser.init().then(async() => { + console.log('Loaded Parser'); + const parser = new Parser(); + + const JavaScript = await Parser.Language.load('http://localhost:3000/static/swh_ql.wasm'); + parser.setLanguage(JavaScript); + + const autocomplete = new Autocomplete( + {inputBox, suggestions: ['('].concat(filterNames)} + ); + + const getSuggestions = () => { + const tree = parser.parse(inputBox.value); + + if (tree.rootNode.hasError()) { + inputBox.classList.add('invalid'); + } else { inputBox.classList.remove('invalid'); } + + console.log(`input(${inputBox.value}) => ${tree.rootNode.toString()}`); + const suggestions = suggestNextNode(tree, inputBox); + + autocomplete.suggestions = suggestions.map(item => `${item} `); + }; + + inputBox.addEventListener('input', getSuggestions.bind(this)); + }); +}; + +/* eslint-enable */ diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "typeface-alegreya": "^1.1.13", "typeface-alegreya-sans": "^1.1.13", "waypoints": "^4.0.1", + "web-tree-sitter": "^0.19.4", "whatwg-fetch": "^3.6.2" }, "devDependencies": { diff --git a/swh/web/config.py b/swh/web/config.py --- a/swh/web/config.py +++ b/swh/web/config.py @@ -59,7 +59,7 @@ ), "search_config": ( "dict", - {"backend": "swh-indexer-storage", "enable_ql": False}, # or "swh-search" + {"backend": "swh-indexer-storage", "enable_ql": True}, # or "swh-search" ), "log_dir": ("string", "/tmp/swh/log"), "debug": ("bool", False), diff --git a/swh/web/templates/includes/origin-search-form.html b/swh/web/templates/includes/origin-search-form.html --- a/swh/web/templates/includes/origin-search-form.html +++ b/swh/web/templates/includes/origin-search-form.html @@ -10,7 +10,8 @@ + oninput="swh.webapp.validateSWHIDInput(this)" + autofocus required autocomplete="off">
diff --git a/yarn.lock b/yarn.lock --- a/yarn.lock +++ b/yarn.lock @@ -13309,6 +13309,11 @@ dependencies: minimalistic-assert "^1.0.0" +web-tree-sitter@^0.19.4: + version "0.19.4" + resolved "https://registry.yarnpkg.com/web-tree-sitter/-/web-tree-sitter-0.19.4.tgz#975076e233204de9063e7a7bda1138c4b454b424" + integrity sha512-8G0xBj05hqZybCqBtW7RPZ/hWEtP3DiLTauQzGJZuZYfVRgw7qj7iaZ+8djNqJ4VPrdOO+pS2dR1JsTbsLxdYg== + webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"