From 5a0f8b3453ee4aafa0b302d01a5bcb00505d325f Mon Sep 17 00:00:00 2001 From: Quincy Morgan Date: Thu, 28 Mar 2019 19:34:47 -0400 Subject: [PATCH] Autocomplete labels in the Wikidata field (close #5544) --- css/80_app.css | 42 ++++---- modules/services/wikidata.js | 48 +++++++-- modules/ui/fields/access.js | 4 +- modules/ui/fields/cycleway.js | 4 +- modules/ui/fields/radio.js | 6 +- modules/ui/fields/wikidata.js | 197 +++++++++++++++++++++------------- 6 files changed, 193 insertions(+), 108 deletions(-) diff --git a/css/80_app.css b/css/80_app.css index 6296a5d87..bfef7c175 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -1591,7 +1591,7 @@ button.preset-favorite-button.active .icon { /* Buttons inside fields */ .form-field-button { - flex: 0 0 32px; + flex: 0 0 auto; height: 30px; width: 32px; position: relative; @@ -1635,7 +1635,7 @@ button.preset-favorite-button.active .icon { /* Field - lists with labeled input items ------------------------------------------------------- */ -.form-field ul.labeled-inputs { +.form-field ul.rows { flex: 1 1 auto; border: 1px solid #ccc; border-top: 0; @@ -1643,32 +1643,36 @@ button.preset-favorite-button.active .icon { overflow: hidden; width: 100%; } -.form-field ul.labeled-inputs li { +.form-field ul.rows li { border-top: 1px solid #ccc; +} +.form-field ul.rows li:first-child { + border-top: 0; +} +.form-field ul.rows li { display: flex; flex-flow: row nowrap; } -.form-field ul.labeled-inputs li:first-child { - border-top: 0; -} -.form-field ul.labeled-inputs li > span, -.form-field ul.labeled-inputs li > div { +.form-field ul.rows li.labeled-input > span, +.form-field ul.rows li.labeled-input > div { flex: 1 1 auto; width: 100%; border-radius: 0; } -.form-field ul.labeled-inputs li input { +.form-field ul.rows li input { border-radius: 0; + border-width: 0; width: 100%; } -.form-field ul.labeled-inputs li input, -.form-field ul.labeled-inputs li button { +.form-field ul.rows li button { border-width: 0; +} +[dir='ltr'] .form-field ul.rows li.labeled-input input, +[dir='ltr'] .form-field ul.rows li button { border-left-width: 1px; } -[dir='rtl'] .form-field ul.labeled-inputs li input, -[dir='rtl'] .form-field ul.labeled-inputs li button { - border-left-width: 0; +[dir='rtl'] .form-field ul.rows li.labeled-input input, +[dir='rtl'] .form-field ul.rows li button { border-right-width: 1px; } @@ -1683,7 +1687,7 @@ button.preset-favorite-button.active .icon { border-top: 0px; border-radius: 0 0 4px 4px; } -.structure-extras-wrap > ul.labeled-inputs { +.structure-extras-wrap > ul.rows { border: 1px solid #ccc; border-radius: 4px; } @@ -2058,10 +2062,12 @@ button.preset-favorite-button.active .icon { [dir='rtl'] .wiki-title-container > input.wiki-title { border-radius: 0 0 4px 0; } -.wiki-title-container > button.wiki-link { +.wiki-title-container > button.wiki-link, +.form-field-wikidata ul.rows li:last-child button.form-field-button:last-child { border-radius: 0 0 4px 0; } -[dir='rtl'] .wiki-title-container > button.wiki-link { +[dir='rtl'] .wiki-title-container > button.wiki-link, +[dir='rtl'] .form-field-wikidata ul.rows li:last-child button.form-field-button:last-child { border-radius: 0 0 0 4px; } @@ -2218,7 +2224,7 @@ div.combobox { display: inline-block; position: relative; height: 30px; - width: 30px; + width: 30px !important; margin-left: -30px; vertical-align: top; cursor: pointer; diff --git a/modules/services/wikidata.js b/modules/services/wikidata.js index faf50bf5f..9dfe3c58f 100644 --- a/modules/services/wikidata.js +++ b/modules/services/wikidata.js @@ -16,6 +16,35 @@ export default { }, + // Search for Wikidata items matching the query + itemsForSearchQuery: function(query, callback) { + if (!query) { + callback('No query', {}); + return; + } + + d3_json(apibase + utilQsString({ + action: 'wbsearchentities', + format: 'json', + formatversion: 2, + search: query, + type: 'item', + language: this.languagesToQuery()[0], + limit: 10, + origin: '*' + }), function(err, data) { + if (data && data.error) { + err = data.error; + } + if (err) { + callback(err, {}); + } else { + callback(null, data.search || {}); + } + }); + }, + + // Given a Wikipedia language and article title, return an array of // corresponding Wikidata entities. itemsByTitle: function(lang, title, callback) { @@ -46,6 +75,13 @@ export default { }); }, + languagesToQuery: function() { + return utilArrayUniq([ + currentLocale.toLowerCase(), + currentLocale.split('-', 2)[0].toLowerCase(), + 'en' + ]); + }, entityByQID: function(qid, callback) { if (!qid) { @@ -57,11 +93,7 @@ export default { return; } - var langs = utilArrayUniq([ - currentLocale.toLowerCase(), - currentLocale.split('-', 2)[0].toLowerCase(), - 'en' - ]); + var langs = this.languagesToQuery(); d3_json(apibase + utilQsString({ action: 'wbgetentities', @@ -144,11 +176,7 @@ export default { if (entity.sitelinks) { // must be one of these that we requested.. - var langs = utilArrayUniq([ - currentLocale.toLowerCase(), - currentLocale.split('-', 2)[0].toLowerCase(), - 'en' - ]); + var langs = this.languagesToQuery(); var englishLocale = (currentLocale.split('-', 2)[0].toLowerCase() === 'en'); for (i = 0; i < langs.length; i++) { // check each, in order of preference diff --git a/modules/ui/fields/access.js b/modules/ui/fields/access.js index f3ef8c33d..4256313e5 100644 --- a/modules/ui/fields/access.js +++ b/modules/ui/fields/access.js @@ -23,7 +23,7 @@ export function uiFieldAccess(field, context) { list = list.enter() .append('ul') - .attr('class', 'labeled-inputs') + .attr('class', 'rows') .merge(list); @@ -33,7 +33,7 @@ export function uiFieldAccess(field, context) { // Enter var enter = items.enter() .append('li') - .attr('class', function(d) { return 'preset-access-' + d; }); + .attr('class', function(d) { return 'labeled-input preset-access-' + d; }); enter .append('span') diff --git a/modules/ui/fields/cycleway.js b/modules/ui/fields/cycleway.js index da596784b..3058c98c4 100644 --- a/modules/ui/fields/cycleway.js +++ b/modules/ui/fields/cycleway.js @@ -31,7 +31,7 @@ export function uiFieldCycleway(field, context) { div = div.enter() .append('ul') - .attr('class', 'labeled-inputs') + .attr('class', 'rows') .merge(div); var keys = ['cycleway:left', 'cycleway:right']; @@ -41,7 +41,7 @@ export function uiFieldCycleway(field, context) { var enter = items.enter() .append('li') - .attr('class', function(d) { return 'preset-cycleway-' + stripcolon(d); }); + .attr('class', function(d) { return 'labeled-input preset-cycleway-' + stripcolon(d); }); enter .append('span') diff --git a/modules/ui/fields/radio.js b/modules/ui/fields/radio.js index 04413dce1..96d4fdade 100644 --- a/modules/ui/fields/radio.js +++ b/modules/ui/fields/radio.js @@ -97,7 +97,7 @@ export function uiFieldRadio(field, context) { list = list.enter() .append('ul') - .attr('class', 'labeled-inputs') + .attr('class', 'rows') .merge(list); @@ -122,7 +122,7 @@ export function uiFieldRadio(field, context) { // Enter var typeEnter = typeItem.enter() .insert('li', ':first-child') - .attr('class', 'structure-type-item'); + .attr('class', 'labeled-input structure-type-item'); typeEnter .append('span') @@ -167,7 +167,7 @@ export function uiFieldRadio(field, context) { // Enter var layerEnter = layerItem.enter() .append('li') - .attr('class', 'structure-layer-item'); + .attr('class', 'labeled-input structure-layer-item'); layerEnter .append('span') diff --git a/modules/ui/fields/wikidata.js b/modules/ui/fields/wikidata.js index 3c7fe5524..37e775fbe 100644 --- a/modules/ui/fields/wikidata.js +++ b/modules/ui/fields/wikidata.js @@ -5,6 +5,8 @@ import { event as d3_event } from 'd3-selection'; +import { uiCombobox } from '../index'; + import { services } from '../../services/index'; import { svgIcon } from '../../svg/index'; @@ -17,13 +19,18 @@ import { import { t } from '../../util/locale'; -export function uiFieldWikidata(field) { +export function uiFieldWikidata(field, context) { var wikidata = services.wikidata; var dispatch = d3_dispatch('change'); - var link = d3_select(null); - var title = d3_select(null); + var searchInput = d3_select(null); + var _qid = null; + var _wikidataEntity = null; var _wikiURL = ''; + var _entity; + var combobox = uiCombobox(context, 'combo-' + field.safeid) + .caseSensitive(true) + .minItems(1); function wiki(selection) { @@ -41,18 +48,59 @@ export function uiFieldWikidata(field) { list = list.enter() .append('ul') - .attr('class', 'labeled-inputs') + .attr('class', 'rows') .merge(list); - var wikidataProperties = ['identifier', 'label', 'description']; + var searchRow = list.selectAll('li.wikidata-search') + .data([0]); - var items = list.selectAll('li') + var searchRowEnter = searchRow.enter() + .append('li') + .attr('class', 'wikidata-search'); + + searchInput = searchRowEnter + .append('input') + .attr('type', 'text') + .style('flex', '1') + .call(utilNoAuto); + + searchInput + .on('focus', function() { + var node = d3_select(this).node(); + node.setSelectionRange(0, node.value.length); + }) + .on('blur', function() { + setLabelForEntity(); + }) + .call(combobox.fetcher(fetchWikidataItems)); + + combobox.on('accept', function(d) { + _qid = d.id; + change(); + }).on('cancel', function() { + setLabelForEntity(); + }); + + searchRowEnter + .append('button') + .attr('class', 'form-field-button wiki-link') + .attr('title', t('icons.open_wikidata')) + .attr('tabindex', -1) + .call(svgIcon('#iD-icon-out-link')) + .on('click', function() { + d3_event.preventDefault(); + if (_wikiURL) window.open(_wikiURL, '_blank'); + }); + + var wikidataProperties = ['description', 'identifier']; + + var items = list.selectAll('li.labeled-input') .data(wikidataProperties); // Enter var enter = items.enter() .append('li') - .attr('class', function(d) { return 'preset-wikidata-' + d; }); + .attr('class', function(d) { return 'labeled-input preset-wikidata-' + d; }); enter .append('span') @@ -60,55 +108,17 @@ export function uiFieldWikidata(field) { .attr('for', function(d) { return 'preset-input-wikidata-' + d; }) .text(function(d) { return t('wikidata.' + d); }); - var inputWrap = enter - .append('div') - .attr('class', 'input-wrap'); - - inputWrap + enter .append('input') .attr('type', 'text') - .attr('class', 'preset-input-wikidata') - .attr('id', function(d) { return 'preset-input-wikidata-' + d; }); - - - title = wrap.select('.preset-wikidata-identifier input') + .attr('id', function(d) { return 'preset-input-wikidata-' + d; }) .call(utilNoAuto) - .merge(title); - - title - .on('blur', blur) - .on('change', change); - - var idItem = wrap.select('.preset-wikidata-identifier'); - - idItem.select('button') - .remove(); - - link = idItem - .append('button') - .attr('class', 'form-field-button wiki-link') - .attr('title', t('icons.open_wikidata')) - .attr('tabindex', -1) - .call(svgIcon('#iD-icon-out-link')) - .merge(link); - - link - .on('click', function() { - d3_event.preventDefault(); - if (_wikiURL) window.open(_wikiURL, '_blank'); - }); - - var readOnlyItems = wrap.selectAll('li:not(.preset-wikidata-identifier)'); - - readOnlyItems.select('input') .classed('disabled', 'true') .attr('readonly', 'true'); - readOnlyItems.select('button') - .remove(); - - readOnlyItems.append('button') - .attr('class', 'form-field-button wiki-link') + enter + .append('button') + .attr('class', 'form-field-button') .attr('title', t('icons.copy')) .attr('tabindex', -1) .call(svgIcon('#iD-operation-copy')) @@ -120,55 +130,76 @@ export function uiFieldWikidata(field) { .select(); document.execCommand('copy'); }); + } + function fetchWikidataItems(q, callback) { - function blur() { - change(); + if (!q && _entity) { + q = context.entity(_entity.id).tags.name || ''; + } + + wikidata.itemsForSearchQuery(q, function(err, data) { + if (err) return; + + for (var i in data) { + data[i].value = data[i].label + ' (' + data[i].id + ')'; + data[i].title = data[i].description; + } + + if (callback) callback(data); + }); } function change() { var syncTags = { - wikidata: utilGetSetValue(title) + wikidata: _qid }; dispatch.call('change', this, syncTags); } + function setLabelForEntity() { + var label = ''; + if (_wikidataEntity) { + if (_wikidataEntity.labels && Object.keys(_wikidataEntity.labels).length > 0) { + label = _wikidataEntity.labels[Object.keys(_wikidataEntity.labels)[0]].value; + } + if (label.length === 0) { + label = _wikidataEntity.id.toString(); + } + } + utilGetSetValue(d3_select('li.wikidata-search input'), label); + } + wiki.tags = function(tags) { - var value = tags[field.key] || ''; - utilGetSetValue(title, value); + _qid = tags[field.key] || ''; - if (!/^Q[0-9]*$/.test(value)) { // not a proper QID + if (!/^Q[0-9]*$/.test(_qid)) { // not a proper QID unrecognized(); return; } // QID value in correct format - _wikiURL = 'https://wikidata.org/wiki/' + value; - wikidata.entityByQID(value, function(err, entity) { + _wikiURL = 'https://wikidata.org/wiki/' + _qid; + wikidata.entityByQID(_qid, function(err, entity) { if (err) { unrecognized(); return; } + _wikidataEntity = entity; + + setLabelForEntity(); - var label = ''; var description = ''; - if (entity.labels && Object.keys(entity.labels).length > 0) { - label = entity.labels[Object.keys(entity.labels)[0]].value; - } if (entity.descriptions && Object.keys(entity.descriptions).length > 0) { description = entity.descriptions[Object.keys(entity.descriptions)[0]].value; } - d3_select('.preset-wikidata-label') - .style('display', function(){ - return label.length > 0 ? 'flex' : 'none'; - }) - .select('input') - .attr('value', label); + d3_select('.form-field-wikidata button.wiki-link') + .classed('disabled', false); d3_select('.preset-wikidata-description') .style('display', function(){ @@ -176,18 +207,31 @@ export function uiFieldWikidata(field) { }) .select('input') .attr('value', description); + + d3_select('.preset-wikidata-identifier') + .style('display', function(){ + return entity.id ? 'flex' : 'none'; + }) + .select('input') + .attr('value', entity.id); }); // not a proper QID function unrecognized() { - d3_select('.preset-wikidata-label') - .style('display', 'none'); + _wikidataEntity = null; + setLabelForEntity(); + d3_select('.preset-wikidata-description') .style('display', 'none'); + d3_select('.preset-wikidata-identifier') + .style('display', 'none'); - if (value && value !== '') { - _wikiURL = 'https://wikidata.org/wiki/Special:Search?search=' + value; + d3_select('.form-field-wikidata button.wiki-link') + .classed('disabled', true); + + if (_qid && _qid !== '') { + _wikiURL = 'https://wikidata.org/wiki/Special:Search?search=' + _qid; } else { _wikiURL = ''; } @@ -195,8 +239,15 @@ export function uiFieldWikidata(field) { }; + wiki.entity = function(val) { + if (!arguments.length) return _entity; + _entity = val; + return wiki; + }; + + wiki.focus = function() { - title.node().focus(); + searchInput.node().focus(); };