Skip to content

Commit 9266707

Browse files
tordanstyrasd
andauthored
directionalCombo: handle [key]=left|right|both schema (Support sidewalk=right) (#10935)
To summarize: * `sidewalk=left` should be interpreted as `sidewalk:left=yes` + `sidewalk:right=no` (and v.v. for `sidewalk=right`) * `sidewalk=both` should be interpreted as `sidewalk:left=yes` + `sidewalk:right=yes` * `sidewalk=other` should be interpreted as `sidewalk:left=other` + `sidewalk:right=other` * `sidewalk:both=value` should always be interpreted as `sidewalk:left=value` + `sidewalk:right=value` (also when value=left/right) This should always be the case, regardless if the preset uses `:both` for the common key or not. Also needs to ensure correct handling of multiselections: The fallback mechanism between the ":both" and ":<direction>" tags of a multiselection requires to look at the tags of each selected entity individually: otherwise the undefined state of a specific direction of the field might be overwritten by the value of the ":both" tag on another selected entity. For example: * entity 1 has `sidewalk:both=yes` * entity 2 has `sidewalk:left=yes` The multiselection now must represent that for the `right` direction, there are conflicting values: once 'no', once _unset/undefined_. (see also #9423 / 4c222fd) --------- Co-authored-by: Martin Raifer <martin@raifer.tech>
1 parent 0dfaf09 commit 9266707

File tree

3 files changed

+185
-22
lines changed

3 files changed

+185
-22
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ _Breaking developer changes, which may affect downstream projects or sites that
4242
* Don't suggest values from Taginfo for `addr:*` tags ([#11733], thanks [@k-yle])
4343
* Don't suggest values from Taginfo for tags with less than 100 uses, even if they're documented on the wiki ([#11794], thanks [@Kaushik4141])
4444
* Don't suggest values from Taginfo for keys that should only be used on changesets, such as `hashtags` ([#11697], thanks [@bhavyaKhatri2703])
45+
* Handle `<key>=left|right|both` in the `directionalCombo` UI to enable support for `sidewalk=left|right|both` [in the future](#id-tagging-schema/pull/1507) ([#10935], thanks [@tordans], [@k-yle])
4546
#### :scissors: Operations
4647
* Display reflection axis on the map while hovering the reflection operations in the edit menu ([#11774], thanks [@Kaushik4141])
4748
#### :camera: Street-Level
@@ -73,6 +74,7 @@ _Breaking developer changes, which may affect downstream projects or sites that
7374

7475
[#8464]: https://github.com/openstreetmap/iD/issues/8464
7576
[#9401]: https://github.com/openstreetmap/iD/issues/9401
77+
[#10935]: https://github.com/openstreetmap/iD/issues/10935
7678
[#11522]: https://github.com/openstreetmap/iD/issues/11522
7779
[#11589]: https://github.com/openstreetmap/iD/pull/11589
7880
[#11636]: https://github.com/openstreetmap/iD/pull/11636
@@ -91,6 +93,7 @@ _Breaking developer changes, which may affect downstream projects or sites that
9193
[#11862]: https://github.com/openstreetmap/iD/pull/11862
9294
[#11870]: https://github.com/openstreetmap/iD/issues/11870
9395
[#11904]: https://github.com/openstreetmap/iD/issues/11904
96+
[#id-tagging-schema/pull/1507]: https://github.com/openstreetmap/id-tagging-schema/pull/1507
9497
[@ilias52730]: https://github.com/ilias52730
9598
[@Razen04]: https://github.com/Razen04
9699
[@homersimpsons]: https://github.com/homersimpsons

modules/ui/fields/directional_combo.js

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ export function uiFieldDirectionalCombo(field, context) {
99
var dispatch = d3_dispatch('change');
1010
var items = d3_select(null);
1111
var wrap = d3_select(null);
12-
var _tags;
1312

14-
var _combos = {};
13+
/** @type {Record<string, ReturnType<typeof uiFieldCombo>>} */
14+
const _combos = {};
1515

1616
// fallback for schema-builder v5's cycleway field type: can be removed eventually
1717
if (field.type === 'cycleway') {
@@ -85,15 +85,30 @@ export function uiFieldDirectionalCombo(field, context) {
8585

8686
function change(key, newValue) {
8787
const commonKey = field.key;
88-
/** if commonKey ends with :both, this is the key without :both. and vice-verca */
89-
const otherCommonKey = field.key.endsWith(':both')
90-
? field.key.replace(/:both$/, '')
88+
// if commonKey contains ":both", this is the key without :both. and vice-versa
89+
const otherCommonKey = field.key.includes(':both')
90+
? field.key.replace(/:both(:|$)/,'$1')
9191
: `${field.key}:both`;
92+
// this is the key that might contain the direction (e.g. left/right) as a tag value,
93+
// instead of the direction being part of the tag key
94+
const fallbackKey = commonKey.includes(':both') ? otherCommonKey : commonKey;
9295

9396
const otherKey = key === field.keys[0] ? field.keys[1] : field.keys[0];
9497

9598
dispatch.call('change', this, tags => {
96-
const otherValue = tags[otherKey] || tags[commonKey] || tags[otherCommonKey];
99+
let otherValue = tags[otherKey] || tags[commonKey] || tags[otherCommonKey];
100+
101+
if (tags[fallbackKey] === 'both' && !tags[otherKey]) {
102+
otherValue = 'yes';
103+
} else if (tags[fallbackKey] && !tags[otherKey]) {
104+
const directionalKeyRegExp = new RegExp(`:${tags[fallbackKey]}(:|$)`);
105+
if (directionalKeyRegExp.test(otherKey)) {
106+
otherValue = 'yes';
107+
} else if (directionalKeyRegExp.test(key)) {
108+
otherValue = 'no';
109+
}
110+
}
111+
97112
if (newValue === otherValue) {
98113
// both tags match, use the common tag to tag both sides the same way
99114
tags[commonKey] = newValue;
@@ -112,16 +127,61 @@ export function uiFieldDirectionalCombo(field, context) {
112127
}
113128

114129

115-
directionalCombo.tags = function(tags) {
116-
_tags = tags;
117-
118-
const commonKey = field.key.replace(/:both$/, '');
119-
for (let key in _combos) {
120-
const uniqueValues = [... new Set([]
121-
.concat(_tags[commonKey])
122-
.concat(_tags[`${commonKey}:both`])
123-
.concat(_tags[key])
124-
.filter(Boolean))];
130+
directionalCombo.tags = function(_ignored, __test_tags /* for unit tests only */) {
131+
const commonKey = field.key;
132+
// if commonKey contains ":both", this is the key without :both. and vice-versa
133+
const otherCommonKey = field.key.includes(':both')
134+
? field.key.replace(/:both(:|$)/,'$1')
135+
: `${field.key}:both`;
136+
// this is the key that might contain the direction (e.g. left/right) as a tag value,
137+
// instead of the direction being part of the tag key
138+
const fallbackKey = commonKey.includes(':both') ? otherCommonKey : commonKey;
139+
// this is the key that is explicitly for ":both" directions
140+
const bothDirectionsKey = fallbackKey !== commonKey ? commonKey : otherCommonKey;
141+
142+
// harmonize tags of selected entities
143+
const keys = Object.keys(_combos);
144+
const entityTags = Array.isArray(__test_tags)
145+
? __test_tags // for unit tests only
146+
: context.selectedIDs().map(id => context.graph().hasEntity(id).tags);
147+
const combinedTags = {};
148+
for (const key of keys) combinedTags[key] = new Set();
149+
for (const tags of entityTags) {
150+
let hadCommonValue = false;
151+
if (tags[fallbackKey] === 'both') {
152+
// interpret key=both as key:*=yes
153+
for (const key of keys) combinedTags[key].add('yes');
154+
hadCommonValue = true;
155+
} else if (tags[fallbackKey]) {
156+
const directionalKeyRegExp = new RegExp(`:${tags[fallbackKey]}(:|$)`);
157+
if (keys.some(key => directionalKeyRegExp.test(key))) {
158+
// tag value looks like a direction: interpret as key:<value>=yes, key:<other>=no
159+
for (const key of keys) {
160+
if (directionalKeyRegExp.test(key)) {
161+
combinedTags[key].add('yes');
162+
} else {
163+
combinedTags[key].add('no');
164+
}
165+
}
166+
} else {
167+
// tag does not look like a direction: handle like key:both=<value>
168+
for (const key of keys) combinedTags[key].add(tags[fallbackKey]);
169+
}
170+
hadCommonValue = true;
171+
}
172+
if (tags[bothDirectionsKey]) {
173+
// handle ":both" key: set all tags to key:*=<value>
174+
for (const key of keys) combinedTags[key].add(tags[bothDirectionsKey]);
175+
hadCommonValue = true;
176+
}
177+
for (const key of keys) {
178+
if (tags[key] || !hadCommonValue) {
179+
combinedTags[key].add(tags[key]);
180+
}
181+
}
182+
}
183+
for (const key in _combos) {
184+
const uniqueValues = [...combinedTags[key]];
125185
_combos[key].tags({ [key]: uniqueValues.length > 1 ? uniqueValues : uniqueValues[0] });
126186
}
127187
};

test/spec/ui/fields/directional_combo.js

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('iD.uiFieldDirectionalCombo', () => {
2323
it('populates the left/right fields using :left & :right', () => {
2424
const instance = iD.uiFieldDirectionalCombo(field, context);
2525
selection.call(instance);
26-
instance.tags({ 'cycleway:left': 'lane' });
26+
instance.tags(undefined, [{ 'cycleway:left': 'lane' }]);
2727

2828
expect(selection.selectAll('input').nodes()).toHaveLength(2);
2929
const [left, right] = selection.selectAll('input').nodes();
@@ -34,7 +34,7 @@ describe('iD.uiFieldDirectionalCombo', () => {
3434
it('populates the left/right fields using :both', () => {
3535
const instance = iD.uiFieldDirectionalCombo(field, context);
3636
selection.call(instance);
37-
instance.tags({ 'cycleway:both': 'lane' });
37+
instance.tags(undefined, [{ 'cycleway:both': 'lane' }]);
3838

3939
expect(selection.selectAll('input').nodes()).toHaveLength(2);
4040
const [left, right] = selection.selectAll('input').nodes();
@@ -45,7 +45,7 @@ describe('iD.uiFieldDirectionalCombo', () => {
4545
it('populates the left/right fields using the unprefixed tag', () => {
4646
const instance = iD.uiFieldDirectionalCombo(field, context);
4747
selection.call(instance);
48-
instance.tags({ cycleway: 'lane' });
48+
instance.tags(undefined, [{ cycleway: 'lane' }]);
4949

5050
expect(selection.selectAll('input').nodes()).toHaveLength(2);
5151
const [left, right] = selection.selectAll('input').nodes();
@@ -57,7 +57,7 @@ describe('iD.uiFieldDirectionalCombo', () => {
5757
const instance = iD.uiFieldDirectionalCombo(field, context);
5858
selection.call(instance);
5959
const tags = { 'cycleway:left': 'lane', 'cycleway:right': 'shoulder' };
60-
instance.tags(tags);
60+
instance.tags(undefined, [tags]);
6161

6262
const onChange = vi.fn();
6363
instance.on('change', v => onChange(v(tags)));
@@ -79,7 +79,7 @@ describe('iD.uiFieldDirectionalCombo', () => {
7979
const instance = iD.uiFieldDirectionalCombo(field, context);
8080
selection.call(instance);
8181
let tags = { [otherCommonKey]: 'lane' };
82-
instance.tags(tags);
82+
instance.tags(undefined, [tags]);
8383

8484
const onChange = vi.fn();
8585
instance.on('change', v => onChange(tags = v(tags)));
@@ -89,7 +89,6 @@ describe('iD.uiFieldDirectionalCombo', () => {
8989
expect(left.value).toBe('lane');
9090
expect(right.value).toBe('lane');
9191

92-
9392
left.value = 'shoulder';
9493
d3.select(left).dispatch('change');
9594

@@ -108,4 +107,105 @@ describe('iD.uiFieldDirectionalCombo', () => {
108107
});
109108
});
110109
});
110+
111+
describe('handle multiselection', function() {
112+
const field = iD.presetField('name', {
113+
key: 'cycleway:both',
114+
keys: ['cycleway:left', 'cycleway:right'],
115+
});
116+
117+
it('populates the left/right fields using :left/:right and :both', () => {
118+
const instance = iD.uiFieldDirectionalCombo(field, context);
119+
selection.call(instance);
120+
instance.tags(undefined, [{ 'cycleway:left': 'lane', 'cycleway:right': 'lane' }, { 'cycleway:both': 'lane' }]);
121+
122+
expect(selection.selectAll('input').nodes()).toHaveLength(2);
123+
const [left, right] = selection.selectAll('input').nodes();
124+
expect(left.value).toBe('lane');
125+
expect(right.value).toBe('lane');
126+
});
127+
128+
it('missing explicit direction tag should be reported like a conflicting value', () => {
129+
const instance = iD.uiFieldDirectionalCombo(field, context);
130+
selection.call(instance);
131+
instance.tags(undefined, [{ 'cycleway:left': 'lane' }, { 'cycleway:both': 'lane' }]);
132+
133+
expect(selection.selectAll('input').nodes()).toHaveLength(2);
134+
const [left, right] = selection.selectAll('input').nodes();
135+
expect(left.value).toBe('lane');
136+
expect(right.value).toBe('');
137+
});
138+
});
139+
140+
describe('handle `[key]=left|right|both` schema', function() {
141+
const field = iD.presetField('name', {
142+
key: 'cycleway:both',
143+
keys: ['cycleway:left', 'cycleway:right'],
144+
});
145+
146+
it('transforms `both` to yes/yes', () => {
147+
const instance = iD.uiFieldDirectionalCombo(field, context);
148+
selection.call(instance);
149+
instance.tags(undefined, [{ 'cycleway': 'both' }]);
150+
151+
expect(selection.selectAll('input').nodes()).toHaveLength(2);
152+
const [left, right] = selection.selectAll('input').nodes();
153+
expect(left.value).toBe('yes');
154+
expect(right.value).toBe('yes');
155+
});
156+
157+
it('transforms `left` to yes/no', () => {
158+
const instance = iD.uiFieldDirectionalCombo(field, context);
159+
selection.call(instance);
160+
instance.tags(undefined, [{ 'cycleway': 'left' }]);
161+
162+
expect(selection.selectAll('input').nodes()).toHaveLength(2);
163+
const [left, right] = selection.selectAll('input').nodes();
164+
expect(left.value).toBe('yes');
165+
expect(right.value).toBe('no');
166+
});
167+
168+
it('preserves other values', () => {
169+
const instance = iD.uiFieldDirectionalCombo(field, context);
170+
selection.call(instance);
171+
instance.tags(undefined, [{ 'cycleway': 'other' }]);
172+
173+
expect(selection.selectAll('input').nodes()).toHaveLength(2);
174+
const [left, right] = selection.selectAll('input').nodes();
175+
expect(left.value).toBe('other');
176+
expect(right.value).toBe('other');
177+
});
178+
179+
it('can read the value from key=left, but writes to key:left', () => {
180+
const instance = iD.uiFieldDirectionalCombo(field, context);
181+
selection.call(instance);
182+
let tags = { 'cycleway': 'left' };
183+
instance.tags(undefined, [tags]);
184+
185+
const onChange = vi.fn();
186+
instance.on('change', v => onChange(tags = v(tags)));
187+
188+
expect(selection.selectAll('input').nodes()).toHaveLength(2);
189+
const [left, right] = selection.selectAll('input').nodes();
190+
expect(left.value).toBe('yes');
191+
expect(right.value).toBe('no');
192+
193+
left.value = 'separate';
194+
d3.select(left).dispatch('change');
195+
196+
expect(onChange).toHaveBeenCalledTimes(1);
197+
expect(onChange).toHaveBeenNthCalledWith(1, {
198+
'cycleway:left': 'separate', // left was updated
199+
'cycleway:right': 'no',
200+
});
201+
202+
right.value = 'separate';
203+
d3.select(right).dispatch('change');
204+
205+
expect(onChange).toHaveBeenCalledTimes(2);
206+
expect(onChange).toHaveBeenNthCalledWith(2, {
207+
'cycleway:both': 'separate', // now left & right have been updated
208+
});
209+
});
210+
});
111211
});

0 commit comments

Comments
 (0)