diff --git a/data/core.yaml b/data/core.yaml index 2b090370b..1373fb6d1 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -73,7 +73,9 @@ en: cancel_draw: annotation: Canceled drawing. change_role: - annotation: Changed the role of a relation member. + annotation: + one: Changed the role of a relation member. + other: "Changed the roles of {n} relation members." change_tags: annotation: Changed tags. copy: @@ -242,9 +244,13 @@ en: single: This feature can't be downgraded because it has a Wikidata tag. multiple: These features can't be downgraded because some have Wikidata tags. add_member: - annotation: Added a member to a relation. + annotation: + one: Added a member to a relation. + other: "Added {n} members to a relation." delete_member: - annotation: Removed a member from a relation. + annotation: + one: Removed a member from a relation. + other: "Removed {n} members from a relation." reorder_members: annotation: Reordered a relation's members. connect: @@ -683,6 +689,7 @@ en: new_relation: New relation... choose_relation: Choose a parent relation role: Role + multiple_roles: Multiple Roles choose: Select feature type results: one: "{n} result for {search}" diff --git a/dist/locales/en.json b/dist/locales/en.json index 7f618b993..49e098119 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -94,7 +94,10 @@ "annotation": "Canceled drawing." }, "change_role": { - "annotation": "Changed the role of a relation member." + "annotation": { + "one": "Changed the role of a relation member.", + "other": "Changed the roles of {n} relation members." + } }, "change_tags": { "annotation": "Changed tags." @@ -323,10 +326,16 @@ } }, "add_member": { - "annotation": "Added a member to a relation." + "annotation": { + "one": "Added a member to a relation.", + "other": "Added {n} members to a relation." + } }, "delete_member": { - "annotation": "Removed a member from a relation." + "annotation": { + "one": "Removed a member from a relation.", + "other": "Removed {n} members from a relation." + } }, "reorder_members": { "annotation": "Reordered a relation's members." @@ -882,6 +891,7 @@ "new_relation": "New relation...", "choose_relation": "Choose a parent relation", "role": "Role", + "multiple_roles": "Multiple Roles", "choose": "Select feature type", "results": { "one": "{n} result for {search}", diff --git a/modules/actions/delete_members.js b/modules/actions/delete_members.js new file mode 100644 index 000000000..2ef06b9f9 --- /dev/null +++ b/modules/actions/delete_members.js @@ -0,0 +1,14 @@ +import { actionDeleteMember } from './delete_member'; + + +export function actionDeleteMembers(relationId, memberIndexes) { + return function(graph) { + // Remove the members in descending order so removals won't shift what members + // are at the remaining indexes + memberIndexes.sort((a, b) => b - a); + for (var i in memberIndexes) { + graph = actionDeleteMember(relationId, memberIndexes[i])(graph); + } + return graph; + }; +} diff --git a/modules/ui/sections/raw_member_editor.js b/modules/ui/sections/raw_member_editor.js index da23790b0..5f52dcfc9 100644 --- a/modules/ui/sections/raw_member_editor.js +++ b/modules/ui/sections/raw_member_editor.js @@ -87,7 +87,9 @@ export function uiSectionRawMemberEditor(context) { var member = { id: d.id, type: d.type, role: newRole }; context.perform( actionChangeMember(d.relation.id, member, d.index), - t('operations.change_role.annotation') + t('operations.change_role.annotation', { + n: 1 + }) ); context.validator().validate(); } @@ -101,7 +103,9 @@ export function uiSectionRawMemberEditor(context) { context.perform( actionDeleteMember(d.relation.id, d.index), - t('operations.delete_member.annotation') + t('operations.delete_member.annotation', { + n: 1 + }) ); if (!context.hasEntity(d.relation.id)) { diff --git a/modules/ui/sections/raw_membership_editor.js b/modules/ui/sections/raw_membership_editor.js index c9841178f..ec2f1a4f0 100644 --- a/modules/ui/sections/raw_membership_editor.js +++ b/modules/ui/sections/raw_membership_editor.js @@ -8,7 +8,7 @@ import { t, localizer } from '../../core/localizer'; import { actionAddEntity } from '../../actions/add_entity'; import { actionAddMember } from '../../actions/add_member'; import { actionChangeMember } from '../../actions/change_member'; -import { actionDeleteMember } from '../../actions/delete_member'; +import { actionDeleteMembers } from '../../actions/delete_members'; import { modeSelect } from '../../modes/select'; import { osmEntity, osmRelation } from '../../osm'; @@ -17,20 +17,18 @@ import { svgIcon } from '../../svg/icon'; import { uiCombobox } from '../combobox'; import { uiSection } from '../section'; import { uiTooltip } from '../tooltip'; -import { utilArrayGroupBy, utilDisplayName, utilNoAuto, utilHighlightEntities, utilUniqueDomId } from '../../util'; +import { utilArrayGroupBy, utilArrayIntersection } from '../../util/array'; +import { utilDisplayName, utilNoAuto, utilHighlightEntities, utilUniqueDomId } from '../../util'; export function uiSectionRawMembershipEditor(context) { var section = uiSection('raw-membership-editor', context) .shouldDisplay(function() { - return _entityIDs && _entityIDs.length === 1; + return _entityIDs && _entityIDs.length; }) .label(function() { - var entity = context.hasEntity(_entityIDs[0]); - if (!entity) return ''; - - var parents = context.graph().parentRelations(entity); + var parents = getSharedParentRelations(); var gt = parents.length > _maxMemberships ? '>' : ''; var count = gt + parents.slice(0, _maxMemberships).length; return t('inspector.title_count', { title: t.html('inspector.relations'), count: count }); @@ -52,6 +50,72 @@ export function uiSectionRawMembershipEditor(context) { var _showBlank; var _maxMemberships = 1000; + function getSharedParentRelations() { + var parents = []; + for (var i = 0; i < _entityIDs.length; i++) { + var entity = context.graph().hasEntity(_entityIDs[i]); + if (!entity) continue; + + if (i === 0) { + parents = context.graph().parentRelations(entity); + } else { + parents = utilArrayIntersection(parents, context.graph().parentRelations(entity)); + } + if (!parents.length) break; + } + return parents; + } + + function getMemberships() { + + var memberships = []; + var relations = getSharedParentRelations().slice(0, _maxMemberships); + + var isMultiselect = _entityIDs.length > 1; + + var i, relation, membership, index, member, indexedMember; + for (i = 0; i < relations.length; i++) { + relation = relations[i]; + membership = { + relation: relation, + members: [], + hash: osmEntity.key(relation) + }; + for (index = 0; index < relation.members.length; index++) { + member = relation.members[index]; + if (_entityIDs.indexOf(member.id) !== -1) { + indexedMember = Object.assign({}, member, { index: index }); + membership.members.push(indexedMember); + membership.hash += ',' + index.toString(); + + if (!isMultiselect) { + // For single selections, list one entry per membership per relation. + // For multiselections, list one entry per relation. + + memberships.push(membership); + membership = { + relation: relation, + members: [], + hash: osmEntity.key(relation) + }; + } + } + } + if (membership.members.length) memberships.push(membership); + } + + memberships.forEach(function(membership) { + membership.domId = utilUniqueDomId('membership-' + membership.relation.id); + var roles = []; + membership.members.forEach(function(member) { + if (roles.indexOf(member.role) === -1) roles.push(member.role); + }); + membership.role = roles.length === 1 ? roles[0] : roles; + }); + + return memberships; + } + function selectRelation(d3_event, d) { d3_event.preventDefault(); @@ -76,14 +140,28 @@ export function uiSectionRawMembershipEditor(context) { if (d === 0) return; // called on newrow (shouldn't happen) if (_inChange) return; // avoid accidental recursive call #5731 - var oldRole = d.member.role; var newRole = context.cleanRelationRole(d3_select(this).property('value')); - if (oldRole !== newRole) { + if (!newRole.trim() && typeof d.role !== 'string') return; + + var membersToUpdate = d.members.filter(function(member) { + return member.role !== newRole; + }); + + if (membersToUpdate.length) { _inChange = true; context.perform( - actionChangeMember(d.relation.id, Object.assign({}, d.member, { role: newRole }), d.index), - t('operations.change_role.annotation') + function actionChangeMemberRoles(graph) { + membersToUpdate.forEach(function(member) { + var newMember = Object.assign({}, member, { role: newRole }); + delete newMember.index; + graph = actionChangeMember(d.relation.id, newMember, member.index)(graph); + }); + return graph; + }, + t('operations.change_role.annotation', { + n: membersToUpdate.length + }) ); context.validator().validate(); } @@ -95,12 +173,22 @@ export function uiSectionRawMembershipEditor(context) { this.blur(); // avoid keeping focus on the button _showBlank = false; - var member = { id: _entityIDs[0], type: context.entity(_entityIDs[0]).type, role: role }; + function actionAddMembers(relationId, ids, role) { + return function(graph) { + for (var i in ids) { + var member = { id: ids[i], type: graph.entity(ids[i]).type, role: role }; + graph = actionAddMember(relationId, member)(graph); + } + return graph; + }; + } if (d.relation) { context.perform( - actionAddMember(d.relation.id, member), - t('operations.add_member.annotation') + actionAddMembers(d.relation.id, _entityIDs, role), + t('operations.add_member.annotation', { + n: _entityIDs.length + }) ); context.validator().validate(); @@ -108,7 +196,7 @@ export function uiSectionRawMembershipEditor(context) { var relation = osmRelation(); context.perform( actionAddEntity(relation), - actionAddMember(relation.id, member), + actionAddMembers(relation.id, _entityIDs, role), t('operations.add.annotation.relation') ); // changing the mode also runs `validate` @@ -124,9 +212,15 @@ export function uiSectionRawMembershipEditor(context) { // remove the hover-highlight styling utilHighlightEntities([d.relation.id], false, context); + var indexes = d.members.map(function(member) { + return member.index; + }); + context.perform( - actionDeleteMember(d.relation.id, d.index), - t('operations.delete_member.annotation') + actionDeleteMembers(d.relation.id, indexes), + t('operations.delete_member.annotation', { + n: _entityIDs.length + }) ); context.validator().validate(); } @@ -197,25 +291,7 @@ export function uiSectionRawMembershipEditor(context) { function renderDisclosureContent(selection) { - var entityID = _entityIDs[0]; - - var entity = context.entity(entityID); - var parents = context.graph().parentRelations(entity); - - var memberships = []; - - parents.slice(0, _maxMemberships).forEach(function(relation) { - relation.members.forEach(function(member, index) { - if (member.id === entity.id) { - memberships.push({ - relation: relation, - member: member, - index: index, - domId: utilUniqueDomId(entityID + '-membership-' + relation.id + '-' + index) - }); - } - }); - }); + var memberships = getMemberships(); var list = selection.selectAll('.member-list') .data([0]); @@ -228,7 +304,7 @@ export function uiSectionRawMembershipEditor(context) { var items = list.selectAll('li.member-row-normal') .data(memberships, function(d) { - return osmEntity.key(d.relation) + ',' + d.index; + return d.hash; }); items.exit() @@ -299,9 +375,19 @@ export function uiSectionRawMembershipEditor(context) { return d.domId; }) .property('type', 'text') - .attr('placeholder', t('inspector.role')) + .property('value', function(d) { + return typeof d.role === 'string' ? d.role : ''; + }) + .attr('title', function(d) { + return Array.isArray(d.role) ? d.role.filter(Boolean).join('\n') : d.role; + }) + .attr('placeholder', function(d) { + return Array.isArray(d.role) ? t('inspector.multiple_roles') : t('inspector.role'); + }) + .classed('mixed', function(d) { + return Array.isArray(d.role); + }) .call(utilNoAuto) - .property('value', function(d) { return d.member.role; }) .on('blur', changeRole) .on('change', changeRole); @@ -309,7 +395,6 @@ export function uiSectionRawMembershipEditor(context) { wrapEnter.each(bindTypeahead); } - var newMembership = list.selectAll('.member-row-new') .data(_showBlank ? [0] : []); @@ -450,7 +535,7 @@ export function uiSectionRawMembershipEditor(context) { taginfo.roles({ debounce: true, rtype: rtype || '', - geometry: context.graph().geometry(entityID), + geometry: context.graph().geometry(_entityIDs[0]), query: role }, function(err, data) { if (!err) callback(sort(role, data)); diff --git a/modules/validations/missing_role.js b/modules/validations/missing_role.js index 5e3f3d863..c1bb24500 100644 --- a/modules/validations/missing_role.js +++ b/modules/validations/missing_role.js @@ -65,7 +65,9 @@ export function validationMissingRole() { onClick: function(context) { context.perform( actionDeleteMember(this.issue.entityIds[0], this.issue.data.member.index), - t('operations.delete_member.annotation') + t('operations.delete_member.annotation', { + n: 1 + }) ); } }) @@ -93,7 +95,9 @@ export function validationMissingRole() { var member = { id: this.issue.entityIds[1], type: oldMember.type, role: role }; context.perform( actionChangeMember(this.issue.entityIds[0], member, oldMember.index), - t('operations.change_role.annotation') + t('operations.change_role.annotation', { + n: 1 + }) ); } });