diff --git a/css/80_app.css b/css/80_app.css index f124982a83..cfffb362fa 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -2122,11 +2122,13 @@ input.date-selector { /* nested subfields for name in different languages or date in different formats */ .localized-multilingual, +.field-source, .date-edtf { padding: 0 10px; flex-basis: 100%; } .localized-multilingual .entry, +.field-source .entry, .date-edtf .entry { position: relative; overflow: hidden; @@ -2134,6 +2136,7 @@ input.date-selector { /* draws a little line connecting the multilingual field up to the name field */ .localized-multilingual .entry::before, +.field-source .entry::before, .date-edtf .entry::before { content: ""; display: block; @@ -2153,6 +2156,7 @@ input.date-selector { width: 100%; } .localized-multilingual .entry .localized-value, +.field-source .entry .field-source-value, .date-edtf .entry .date-value { border-top-width: 0; border-radius: 0 0 4px 4px; diff --git a/data/core.yaml b/data/core.yaml index 7659a9a2a8..b17289a04d 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -798,6 +798,9 @@ en: edtf: Add EDTF date edtf_label: Extended Date/Time Format edtf_placeholder: 1849~, 1804?, 189X, 1906/1908, 1814-23, 1960-05-01T13:00... + field_source: add source + field_source_label: Source for {field_name} + field_source_placeholder: URL, newspaper article, book... max_length_reached: "This string is longer than the maximum length of {maxChars} characters. Anything exceeding that length will be truncated." set_today: "Sets the value to today." background: diff --git a/dist/iD.css b/dist/iD.css index ec34a3b6b3..5a2ef03510 100644 --- a/dist/iD.css +++ b/dist/iD.css @@ -5522,11 +5522,13 @@ /* nested subfields for name in different languages or date in different formats */ .ideditor .localized-multilingual, +.ideditor .field-source, .ideditor .date-edtf { padding: 0 10px; flex-basis: 100%; } .ideditor .localized-multilingual .entry, +.ideditor .field-source .entry, .ideditor .date-edtf .entry { position: relative; overflow: hidden; @@ -5534,6 +5536,7 @@ /* draws a little line connecting the multilingual field up to the name field */ .ideditor .localized-multilingual .entry::before, +.ideditor .field-source .entry::before, .ideditor .date-edtf .entry::before { content: ""; display: block; @@ -5553,6 +5556,7 @@ width: 100%; } .ideditor .localized-multilingual .entry .localized-value, +.ideditor .field-source .entry .field-source-value, .ideditor .date-edtf .entry .date-value { border-top-width: 0; border-radius: 0 0 4px 4px; diff --git a/modules/presets/index.js b/modules/presets/index.js index 568304fd80..06501ad6a0 100644 --- a/modules/presets/index.js +++ b/modules/presets/index.js @@ -58,6 +58,7 @@ function addHistoricalFields(fields) { // A combo box would encourage mappers to choose one of the suggestions, but we want mappers to be as detailed as possible. if (fields.source) { fields.source.type = 'text'; + fields.source.source = false; } fields.license = { diff --git a/modules/ui/field.js b/modules/ui/field.js index c7f0f2ca52..be11fba3c2 100644 --- a/modules/ui/field.js +++ b/modules/ui/field.js @@ -10,6 +10,7 @@ import { uiFieldHelp } from './field_help'; import { uiFields } from './fields'; import { uiTagReference } from './tag_reference'; import { utilRebind, utilUniqueDomId } from '../util'; +import { uiSourceSubfield } from './source_subfield'; export function uiField(context, presetField, entityIDs, options) { @@ -28,6 +29,11 @@ export function uiField(context, presetField, entityIDs, options) { var _state = ''; var _tags = {}; + // This sets option.source to true if it has not been defined proviously + // This way, every field will have a source subfield unless explicitly stated otherwise + // Currently, only the main Sources field does not have a sources subfield + options.source = field.source !== undefined ? field.source : true; + var _entityExtent; if (entityIDs && entityIDs.length) { _entityExtent = entityIDs.reduce(function(extent, entityID) { @@ -124,8 +130,10 @@ export function uiField(context, presetField, entityIDs, options) { dispatch.call('change', d, t); } - field.render = function(selection) { + + var sourceSubfield = uiSourceSubfield(context, field, _tags, dispatch); + var container = selection.selectAll('.form-field') .data([field]); @@ -169,6 +177,10 @@ export function uiField(context, presetField, entityIDs, options) { .attr('title', t('icons.undo')) .call(svgIcon((localizer.textDirection() === 'rtl') ? '#iD-icon-redo' : '#iD-icon-undo')); } + + if (options.source){ + sourceSubfield.button(labelEnter, container); + } } @@ -258,6 +270,10 @@ export function uiField(context, presetField, entityIDs, options) { .attr('xlink:href', '#fas-lock'); container.call(_locked ? _lockedTip : _lockedTip.destroy); + + if (options.source){ + sourceSubfield.body(selection); + } }; diff --git a/modules/ui/index.js b/modules/ui/index.js index 2ffbdd11b0..a7a362bda6 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -49,6 +49,7 @@ export { uiPresetList } from './preset_list'; export { uiRestore } from './restore'; export { uiScale } from './scale'; export { uiSidebar } from './sidebar'; +export { uiSourceSubfield } from './source_subfield'; export { uiSourceSwitch } from './source_switch'; export { uiSpinner } from './spinner'; export { uiSplash } from './splash'; diff --git a/modules/ui/source_subfield.js b/modules/ui/source_subfield.js new file mode 100644 index 0000000000..d79dbbcf6b --- /dev/null +++ b/modules/ui/source_subfield.js @@ -0,0 +1,171 @@ +import { + select as d3_select +} from 'd3-selection'; + +import { t } from '../core/localizer'; +import { svgIcon } from '../svg/icon'; +import { uiTooltip } from './tooltip'; +import { utilGetSetValue, utilUniqueDomId } from '../util'; + +export function uiSourceSubfield(context, field, tags, dispatch) { + + var sourceSubfield = {}; + + let sourceInput = d3_select(null); + let sourceKey = field.key + ':source'; + let _sourceValue = tags[sourceKey]; + + // Adapted from renderEDTF from modules/ui/fields/date.js + function renderSourceInput(selection) { + let entries = selection.selectAll('div.entry') + .data((typeof _sourceValue === 'string' || Array.isArray(_sourceValue)) ? [_sourceValue] : []); + + entries.exit() + .style('top', '0') + .style('max-height', '240px') + .transition() + .duration(200) + .style('opacity', '0') + .style('max-height', '0px') + .remove(); + + let entriesEnter = entries.enter() + .append('div') + .attr('class', 'entry') + .each(function() { + var wrap = d3_select(this); + + let domId = utilUniqueDomId('source-' + field.safeid); + let label = wrap + .append('label') + .attr('class', 'field-label') + .attr('for', domId); + + let text = label + .append('span') + .attr('class', 'label-text'); + + text + .append('span') + .attr('class', 'label-textvalue') + .call(t.append('inspector.field_source_label', { field_name: field.title })); + + text + .append('span') + .attr('class', 'label-textannotation'); + + label + .append('button') + .attr('class', 'remove-icon-source') + .attr('title', t('icons.remove')) + .on('click', function(d3_event) { + d3_event.preventDefault(); + + // remove the UI item manually + _sourceValue = undefined; + + let t = {}; + t[sourceKey] = undefined; + dispatch.call('change', this, t); + + renderSourceInput(selection); + }) + .call(svgIcon('#iD-operation-delete')); + + wrap + .append('input') + .attr('type', 'text') + .attr('class', 'field-source-value') + .on('blur', changeSourceValue) + .on('change', changeSourceValue); + }); + + entriesEnter + .style('margin-top', '0px') + .style('max-height', '0px') + .style('opacity', '0') + .transition() + .duration(200) + .style('margin-top', '10px') + .style('max-height', '240px') + .style('opacity', '1') + .on('end', function() { + d3_select(this) + .style('max-height', '') + .style('overflow', 'visible'); + }); + + entries = entries.merge(entriesEnter); + + entries.order(); + + // allow removing the entry UIs even if there isn't a tag to remove + entries.classed('present', true); + + utilGetSetValue(entries.select('.field-source-value'), function(d) { + return typeof d === 'string' ? d : ''; + }) + .attr('title', function(d) { + return Array.isArray(d) ? d.filter(Boolean).join('\n') : null; + }) + .attr('placeholder', function(d) { + return Array.isArray(d) ? t('inspector.multiple_values') : t('inspector.field_source_placeholder'); + }) + .classed('mixed', function(d) { + return Array.isArray(d); + }); + } + + function changeSourceValue(d3_event, d) { + let value = context.cleanTagValue(utilGetSetValue(d3_select(this))) || undefined; + // don't override multiple values with blank string + if (!value && Array.isArray(d.value)) return; + + let t = {}; + t[sourceKey] = value; + d.value = value; + dispatch.call('change', this, t); + } + + function addSource(d3_event) { + d3_event.preventDefault(); + + if (typeof _sourceValue !== 'string' && !Array.isArray(_sourceValue)) { + _sourceValue = ''; + } + sourceInput.call(renderSourceInput); + + } + + sourceSubfield.button = function(labelEnter, container) { + labelEnter + .append('button') + .attr('class', 'source-icon') + .attr('title', function() { + return t('inspector.field_source'); + }) + .call(svgIcon('#fas-at', 'inline')); + + container = container + .merge(labelEnter); + + container.select('.field-label > .source-icon') // propagate bound data + .on('click', addSource); + }; + + + sourceSubfield.body = function(selection) { + sourceInput = selection.selectChild().selectAll('.field-source') + .data([0]); + + sourceInput = sourceInput.enter() + .append('div') + .attr('class', 'field-source') + .merge(sourceInput); + + sourceInput + .call(renderSourceInput); + }; + + return sourceSubfield; +} diff --git a/scripts/build_data.js b/scripts/build_data.js index 8a7c98f04c..603bfb51b1 100644 --- a/scripts/build_data.js +++ b/scripts/build_data.js @@ -71,7 +71,8 @@ function buildData() { 'fas-th-list', 'fas-user-cog', 'fas-calendar-days', - 'fas-rotate' + 'fas-rotate', + 'fas-at' ]); // add icons for QA integrations readQAIssueIcons(faIcons); diff --git a/svg/fontawesome/fas-at.svg b/svg/fontawesome/fas-at.svg new file mode 100644 index 0000000000..e90101f24e --- /dev/null +++ b/svg/fontawesome/fas-at.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/spec/ui/fields/source_subfield.js b/test/spec/ui/fields/source_subfield.js new file mode 100644 index 0000000000..c083f05d9e --- /dev/null +++ b/test/spec/ui/fields/source_subfield.js @@ -0,0 +1,64 @@ +describe('iD.uiSourceSubfield', function() { + let entity, context, selection, field; + + beforeEach(function() { + entity = iD.osmNode({id: 'n12345'}); + context = iD.coreContext().assetPath('../dist/').init(); + context.history().merge([entity]); + selection = d3.select(document.createElement('div')); + field = iD.presetField('name', { key: 'name', type: 'text' }); + }); + + it('adds an source subfield when the @ button is clicked', function(done) { + var uiField = iD.uiField(context, field, ['n12345'], {show: true, wrap: true}); + window.setTimeout(function() { // async, so data will be available + selection.call(uiField.render); + happen.click(selection.selectAll('.source-icon').node()); + expect(selection.selectAll('.field-source').nodes().length).to.equal(1); + done(); + }, 20); + }); + + it('creates field:source tag after setting the value', function(done) { + var uiField = iD.uiField(context, field, ['n12345'], {show: true, wrap: true}); + window.setTimeout(function() { // async, so data will be available + selection.call(uiField.render); + happen.click(selection.selectAll('.source-icon').node()); + + iD.utilGetSetValue(selection.selectAll('.field-source-value'), 'Book 1'); + + uiField.on('change', function(tags) { + expect(tags).to.eql({'name:source': 'Book 1'}); + }); + happen.once(selection.selectAll('.field-source-value').node(), {type: 'change'}); + done(); + }, 20); + }); + + + it('removes the tag when the value is emptied', function(done) { + var uiField = iD.uiField(context, field, ['n12345'], {show: true, wrap: true}); + window.setTimeout(function() { // async, so data will be available + selection.call(uiField.render); + happen.click(selection.selectAll('.source-icon').node()); + iD.utilGetSetValue(selection.selectAll('.field-source-value'), 'abc'); + + uiField.on('change', function(tags) { + expect(tags).to.eql({'name:source': undefined}); + }); + + iD.utilGetSetValue(selection.selectAll('.field-source-value'), ''); + happen.once(selection.selectAll('.field-source-value').node(), {type: 'change'}); + done(); + }, 20); + }); + + it('there is no @ button on main source field', function(done) { + var uiField = iD.uiField(context, {...field, source: false}, ['n12345'], {show: true, wrap: true}); + window.setTimeout(function() { // async, so data will be available + selection.call(uiField.render); + expect(selection.selectAll('.source-icon').nodes().length).to.equal(0); + done(); + }, 20); + }); +});