Refactoring of deleteWizard

This commit is contained in:
Pieter Vander Vennet 2022-05-01 04:17:40 +02:00
parent b941fb2983
commit fd90914c35
8 changed files with 207 additions and 206 deletions

View file

@ -1,23 +1,43 @@
import {Translation} from "../../UI/i18n/Translation";
import {Translation, TypedTranslation} from "../../UI/i18n/Translation";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {DeleteConfigJson} from "./Json/DeleteConfigJson";
import Translations from "../../UI/i18n/Translations";
import {TagUtils} from "../../Logic/Tags/TagUtils";
export default class DeleteConfig {
public static readonly defaultDeleteReasons : {changesetMessage: string, explanation: Translation} [] = [
{
changesetMessage: "testing point",
explanation: Translations.t.delete.reasons.test
},
{
changesetMessage:"disused",
explanation: Translations.t.delete.reasons.disused
},
{
changesetMessage: "not found",
explanation: Translations.t.delete.reasons.notFound
},
{
changesetMessage: "duplicate",
explanation:Translations.t.delete.reasons.duplicate
}
]
public readonly extraDeleteReasons?: {
explanation: Translation,
explanation: TypedTranslation<object>,
changesetMessage: string
}[]
public readonly nonDeleteMappings?: { if: TagsFilter, then: Translation }[]
public readonly nonDeleteMappings?: { if: TagsFilter, then: TypedTranslation<object> }[]
public readonly softDeletionTags?: TagsFilter
public readonly neededChangesets?: number
constructor(json: DeleteConfigJson, context: string) {
this.extraDeleteReasons = json.extraDeleteReasons?.map((reason, i) => {
this.extraDeleteReasons = (json.extraDeleteReasons ?? []).map((reason, i) => {
const ctx = `${context}.extraDeleteReasons[${i}]`
if ((reason.changesetMessage ?? "").length <= 5) {
throw `${ctx}.explanation is too short, needs at least 4 characters`
@ -27,7 +47,7 @@ export default class DeleteConfig {
changesetMessage: reason.changesetMessage
}
})
this.nonDeleteMappings = json.nonDeleteMappings?.map((nonDelete, i) => {
this.nonDeleteMappings = (json.nonDeleteMappings??[]).map((nonDelete, i) => {
const ctx = `${context}.extraDeleteReasons[${i}]`
return {
if: TagUtils.Tag(nonDelete.if, ctx + ".if"),

View file

@ -36,7 +36,17 @@ export interface DeleteConfigJson {
* By adding a 'nonDeleteMapping', an option can be added into the list which will retag the feature.
* It is important that the feature will be retagged in such a way that it won't be picked up by the layer anymore!
*/
nonDeleteMappings?: { if: AndOrTagConfigJson, then: string | any }[],
nonDeleteMappings?: {
/**
* The tags that will be given to the object.
* This must remove tags so that the 'source/osmTags' won't match anymore
*/
if: AndOrTagConfigJson,
/**
* The human explanation for the options
*/
then: string | any,
}[],
/**
* In some cases, the contributor is not allowed to delete the current feature (e.g. because it isn't a point, the point is referenced by a relation or the user isn't experienced enough).
@ -63,4 +73,5 @@ export interface DeleteConfigJson {
* For some small features (e.g. bicycle racks) this is too much and this requirement can be lowered or dropped, which can be done here.
*/
neededChangesets?: number
}

View file

@ -54,7 +54,7 @@ export default class MoreScreen extends Combine {
if(searchTerm === "personal"){
window.location.href = MoreScreen.createUrlFor({id: "personal"}, false, state).data
}
if(searchTerm === "bugs") {
if(searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
}
if(searchTerm === "source") {

View file

@ -12,7 +12,7 @@ export class RadioButton<T> extends InputElement<T> {
constructor(
elements: InputElement<T>[],
options?: {
selectFirstAsDefault?: boolean,
selectFirstAsDefault?: true | boolean,
dontStyle?: boolean
}
) {

View file

@ -5,23 +5,26 @@ import Svg from "../../Svg";
import DeleteAction from "../../Logic/Osm/Actions/DeleteAction";
import {UIEventSource} from "../../Logic/UIEventSource";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import TagRenderingQuestion from "./TagRenderingQuestion";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import {FixedUiElement} from "../Base/FixedUiElement";
import {Translation} from "../i18n/Translation";
import BaseUIElement from "../BaseUIElement";
import Constants from "../../Models/Constants";
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig";
import {AndOrTagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson";
import DeleteConfig from "../../Models/ThemeConfig/DeleteConfig";
import {OsmObject} from "../../Logic/Osm/OsmObject";
import {ElementStorage} from "../../Logic/ElementStorage";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Changes} from "../../Logic/Osm/Changes";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import OsmChangeAction from "../../Logic/Osm/Actions/OsmChangeAction";
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
import {InputElement} from "../Input/InputElement";
import {RadioButton} from "../Input/RadioButton";
import {FixedInputElement} from "../Input/FixedInputElement";
import Title from "../Base/Title";
import {SubstitutedTranslation} from "../SubstitutedTranslation";
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
import TagRenderingQuestion from "./TagRenderingQuestion";
export default class DeleteWizard extends Toggle {
/**
* The UI-element which triggers 'deletion' (either soft or hard).
*
@ -42,12 +45,7 @@ export default class DeleteWizard extends Toggle {
* @param options softDeletionTags: the tags to apply if the user doesn't have permission to delete, e.g. 'disused:amenity=public_bookcase', 'amenity='. After applying, the element should not be picked up on the map anymore. If undefined, the wizard will only show up if the point can be (hard) deleted
*/
constructor(id: string,
state: {
osmConnection: OsmConnection;
allElements: ElementStorage,
layoutToUse?: LayoutConfig,
changes?: Changes
},
state: FeaturePipelineState,
options: DeleteConfig) {
@ -60,70 +58,88 @@ export default class DeleteWizard extends Toggle {
const confirm = new UIEventSource<boolean>(false)
function doDelete(selected: TagsFilter) {
// Selected == the reasons, not the tags of the object
const tgs = selected.asChange(tagsSource.data)
const deleteReasonMatch = tgs.filter(kv => kv.k === "_delete_reason")
if (deleteReasonMatch.length === 0) {
return;
/**
* This function is the actual delete function
*/
function doDelete(selected: { deleteReason: string } | { retagTo: TagsFilter }) {
let actionToTake: OsmChangeAction;
if (selected["retagTo"] !== undefined) {
// no _delete_reason is given, which implies that this is _not_ a deletion but merely a retagging via a nonDeleteMapping
actionToTake = new ChangeTagAction(
id,
selected["retagTo"],
tagsSource.data,
{
theme: state?.layoutToUse?.id ?? "unkown",
changeType: "special-delete"
}
)
} else {
actionToTake = new DeleteAction(id,
options.softDeletionTags,
{
theme: state?.layoutToUse?.id ?? "unkown",
specialMotivation: selected["deleteReason"]
},
deleteAbility.canBeDeleted.data.canBeDeleted
)
}
const deleteAction = new DeleteAction(id,
options.softDeletionTags,
{
theme: state?.layoutToUse?.id ?? "unkown",
specialMotivation: deleteReasonMatch[0]?.v
},
deleteAbility.canBeDeleted.data.canBeDeleted
)
state.changes?.applyAction(deleteAction)
state.changes?.applyAction(actionToTake)
isDeleted.setData(true)
}
const t = Translations.t.delete
const cancelButton = t.cancel.Clone().SetClass("block btn btn-secondary").onClick(() => confirm.setData(false));
const question = new VariableUiElement(tagsSource.map(currentTags => {
const config = DeleteWizard.generateDeleteTagRenderingConfig(options.softDeletionTags, options.nonDeleteMappings, options.extraDeleteReasons, currentTags)
return new TagRenderingQuestion(
tagsSource,
config,
state,
{
cancelButton,
/*Using a custom save button constructor erases all logic to actually save, so we have to listen for the click!*/
saveButtonConstr: (v) => DeleteWizard.constructConfirmButton(v).onClick(() => {
doDelete(v.data)
}),
bottomText: (v) => DeleteWizard.constructExplanation(v, deleteAbility)
}
)
}))
const cancelButton = t.cancel.SetClass("block btn btn-secondary").onClick(() => confirm.setData(false));
/**
* The button which is shown first. Opening it will trigger the check for deletions
*/
const deleteButton = new SubtleButton(
Svg.delete_icon_svg().SetStyle("width: 1.5rem; height: 1.5rem;"), t.delete.Clone()).onClick(
() => {
deleteAbility.CheckDeleteability(true)
confirm.setData(true);
}
)
Svg.delete_icon_svg().SetStyle("width: 1.5rem; height: 1.5rem;"), t.delete)
.onClick(
() => {
deleteAbility.CheckDeleteability(true)
confirm.setData(true);
}
)
const isShown: UIEventSource<boolean> = tagsSource.map(tgs => tgs.id.indexOf("-") < 0)
const deleteOptionPicker = DeleteWizard.constructMultipleChoice(options, tagsSource, state);
const deleteDialog = new Combine([
new Title(new SubstitutedTranslation(t.whyDelete, tagsSource, state)
.SetClass("question-text"), 3),
deleteOptionPicker,
new Combine([
DeleteWizard.constructExplanation(deleteOptionPicker.GetValue(), deleteAbility, tagsSource, state),
new Combine([
cancelButton,
DeleteWizard.constructConfirmButton(deleteOptionPicker.GetValue())
.onClick(() => doDelete(deleteOptionPicker.GetValue().data))
]).SetClass("flex justify-end flex-wrap-reverse")
]).SetClass("flex mt-2 justify-between")
]).SetClass("question")
const isShown = new UIEventSource<boolean>(id.indexOf("-") < 0)
super(
new Toggle(
new Combine([Svg.delete_icon_svg().SetClass("h-16 w-16 p-2 m-2 block bg-gray-300 rounded-full"),
t.isDeleted.Clone()]).SetClass("flex m-2 rounded-full"),
t.isDeleted]).SetClass("flex m-2 rounded-full"),
new Toggle(
new Toggle(
new Toggle(
new Toggle(
question,
new SubtleButton(Svg.envelope_ui(), t.readMessages.Clone()),
deleteDialog,
new SubtleButton(Svg.envelope_ui(), t.readMessages),
state.osmConnection.userDetails.map(ud => ud.csCount > Constants.userJourney.addNewPointWithUnreadMessagesUnlock || ud.unreadMessages == 0)
),
@ -134,16 +150,16 @@ export default class DeleteWizard extends Toggle {
new Combine([
Svg.delete_not_allowed_svg().SetStyle("height: 2rem; width: auto").SetClass("mr-2"),
new Combine([
t.cannotBeDeleted.Clone(),
cbd.reason.Clone().SetClass("subtle"),
t.useSomethingElse.Clone().SetClass("subtle")]).SetClass("flex flex-col")
t.cannotBeDeleted,
cbd.reason.SetClass("subtle"),
t.useSomethingElse.SetClass("subtle")]).SetClass("flex flex-col")
]).SetClass("flex m-2 p-2 rounded-lg bg-gray-200 bg-gray-200")))
,
deleteAbility.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)),
t.loginToDelete.Clone().onClick(state.osmConnection.AttemptLogin),
t.loginToDelete.onClick(state.osmConnection.AttemptLogin),
state.osmConnection.isLoggedIn
),
isDeleted),
@ -153,17 +169,17 @@ export default class DeleteWizard extends Toggle {
}
private static constructConfirmButton(deleteReasons: UIEventSource<TagsFilter>): BaseUIElement {
private static constructConfirmButton(deleteReasons: UIEventSource<any | undefined>): BaseUIElement {
const t = Translations.t.delete;
const btn = new Combine([
Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"),
t.delete.Clone()
t.delete
]).SetClass("flex btn bg-red-500")
const btnNonActive = new Combine([
Svg.delete_icon_ui().SetClass("w-6 h-6 mr-3 block"),
t.delete.Clone()
t.delete
]).SetClass("flex btn btn-disabled bg-red-200")
return new Toggle(
@ -175,111 +191,83 @@ export default class DeleteWizard extends Toggle {
}
private static constructExplanation(tags: UIEventSource<TagsFilter>, deleteAction: DeleteabilityChecker) {
private static constructExplanation(selectedOption: UIEventSource<
{deleteReason: string} | {retagTo: TagsFilter}>, deleteAction: DeleteabilityChecker,
currentTags: UIEventSource<object>,
state?: {osmConnection?: OsmConnection}) {
const t = Translations.t.delete;
return new VariableUiElement(tags.map(
currentTags => {
const cbd = deleteAction.canBeDeleted.data;
if (currentTags === undefined) {
return t.explanations.selectReason.Clone().SetClass("subtle");
return new VariableUiElement(selectedOption.map(
selectedOption => {
if (selectedOption === undefined) {
return t.explanations.selectReason.SetClass("subtle");
}
const hasDeletionTag = currentTags.asChange(currentTags).some(kv => kv.k === "_delete_reason")
if (cbd.canBeDeleted && hasDeletionTag) {
return t.explanations.hardDelete.Clone()
const retag: TagsFilter | undefined = selectedOption["retagTo"]
if(retag !== undefined) {
// This is a retagging, not a deletion of any kind
return new Combine([t.explanations.retagNoOtherThemes,
TagRenderingQuestion.CreateTagExplanation(new UIEventSource<TagsFilter>(retag),
currentTags, state
).SetClass("subtle")
])
}
return new Combine([t.explanations.softDelete.Subs({reason: cbd.reason}),
new FixedUiElement(currentTags.asHumanString(false, true, currentTags)).SetClass("subtle")
]).SetClass("flex flex-col")
const deleteReason = selectedOption["deleteReason"];
if(deleteReason !== undefined){
return new VariableUiElement(deleteAction.canBeDeleted.map(({
canBeDeleted, reason
}) => {
if(canBeDeleted){
// This is a hard delete for which we give an explanation
return t.explanations.hardDelete;
}
// This is a soft deletion: we explain _why_ the deletion is soft
return t.explanations.softDelete.Subs({reason: reason})
}))
}
}
, [deleteAction.canBeDeleted]
)).SetClass("block")
}
private static generateDeleteTagRenderingConfig(softDeletionTags: TagsFilter,
nonDeleteOptions: { if: TagsFilter; then: Translation }[],
extraDeleteReasons: { explanation: Translation; changesetMessage: string }[],
currentTags: any): TagRenderingConfig {
const t = Translations.t.delete
nonDeleteOptions = nonDeleteOptions ?? []
let softDeletionTagsStr = []
if (softDeletionTags !== undefined) {
softDeletionTags.asChange(currentTags)
}
const extraOptionsStr: { if: AndOrTagConfigJson, then: any }[] = []
for (const nonDeleteOption of nonDeleteOptions) {
const newIf: string[] = nonDeleteOption.if.asChange({}).map(kv => kv.k + "=" + kv.v)
private static constructMultipleChoice(config: DeleteConfig, tagsSource: UIEventSource<object>, state: FeaturePipelineState):
InputElement<{ deleteReason: string } | { retagTo: TagsFilter }> {
extraOptionsStr.push({
if: {and: newIf},
then: nonDeleteOption.then
})
const elements: InputElement<{ deleteReason: string } | { retagTo: TagsFilter }>[ ] = []
for (const nonDeleteOption of config.nonDeleteMappings) {
elements.push(new FixedInputElement(
new SubstitutedTranslation(nonDeleteOption.then, tagsSource, state),
{
retagTo: nonDeleteOption.if
}
))
}
for (const extraDeleteReason of (extraDeleteReasons ?? [])) {
extraOptionsStr.push({
if: {and: ["_delete_reason=" + extraDeleteReason.changesetMessage]},
then: extraDeleteReason.explanation
})
for (const extraDeleteReason of (config.extraDeleteReasons ?? [])) {
elements.push(new FixedInputElement(
new SubstitutedTranslation(extraDeleteReason.explanation, tagsSource, state),
{
deleteReason: extraDeleteReason.changesetMessage
}
))
}
return new TagRenderingConfig(
{
question: t.whyDelete,
render: "Deleted because {_delete_reason}",
freeform: {
key: "_delete_reason",
addExtraTags: softDeletionTagsStr
},
mappings: [
...extraOptionsStr,
for (const extraDeleteReason of DeleteConfig.defaultDeleteReasons) {
elements.push(new FixedInputElement(
extraDeleteReason.explanation.Clone(/*Must clone here, as this explanation might be used on many locations*/),
{
deleteReason: extraDeleteReason.changesetMessage
}
))
}
{
if: {
and: [
"_delete_reason=testing point",
...softDeletionTagsStr
]
},
then: t.reasons.test
},
{
if: {
and: [
"_delete_reason=disused",
...softDeletionTagsStr
]
},
then: t.reasons.disused
},
{
if: {
and: [
"_delete_reason=not found",
...softDeletionTagsStr
]
},
then: t.reasons.notFound
},
{
if: {
and: [
"_delete_reason=duplicate",
...softDeletionTagsStr
]
},
then: t.reasons.duplicate
}
]
}, "Delete wizard"
)
return new RadioButton(elements, {selectFirstAsDefault: false});
}
}
class DeleteabilityChecker {

View file

@ -15,13 +15,8 @@ import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {Utils} from "../../Utils";
import MoveWizard from "./MoveWizard";
import Toggle from "../Input/Toggle";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {Changes} from "../../Logic/Osm/Changes";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {ElementStorage} from "../../Logic/ElementStorage";
import FilteredLayer from "../../Models/FilteredLayer";
import BaseLayer from "../../Models/BaseLayer";
import Lazy from "../Base/Lazy";
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
export default class FeatureInfoBox extends ScrollableFullScreen {
@ -29,18 +24,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
public constructor(
tags: UIEventSource<any>,
layerConfig: LayerConfig,
state: {
filteredLayers: UIEventSource<FilteredLayer[]>;
backgroundLayer: UIEventSource<BaseLayer>;
featureSwitchIsTesting: UIEventSource<boolean>;
featureSwitchIsDebugging: UIEventSource<boolean>;
featureSwitchShowAllQuestions: UIEventSource<boolean>;
osmConnection: OsmConnection,
featureSwitchUserbadge: UIEventSource<boolean>,
changes: Changes,
layoutToUse: LayoutConfig,
allElements: ElementStorage
},
state: FeaturePipelineState,
hashToShow?: string,
isShown?: UIEventSource<boolean>,
) {
@ -78,18 +62,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
private static GenerateContent(tags: UIEventSource<any>,
layerConfig: LayerConfig,
state: {
filteredLayers: UIEventSource<FilteredLayer[]>;
backgroundLayer: UIEventSource<BaseLayer>;
featureSwitchIsTesting: UIEventSource<boolean>;
featureSwitchIsDebugging: UIEventSource<boolean>;
featureSwitchShowAllQuestions: UIEventSource<boolean>;
osmConnection: OsmConnection,
featureSwitchUserbadge: UIEventSource<boolean>,
changes: Changes,
layoutToUse: LayoutConfig,
allElements: ElementStorage
}): BaseUIElement {
state: FeaturePipelineState): BaseUIElement {
let questionBoxes: Map<string, QuestionBox> = new Map<string, QuestionBox>();
const allGroupNames = Utils.Dedup(layerConfig.tagRenderings.map(tr => tr.group))
@ -179,7 +152,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
private static createEditElements(questionBoxes: Map<string, QuestionBox>,
layerConfig: LayerConfig,
tags: UIEventSource<any>,
state: { filteredLayers: UIEventSource<FilteredLayer[]>; backgroundLayer: UIEventSource<BaseLayer>; featureSwitchIsTesting: UIEventSource<boolean>; featureSwitchIsDebugging: UIEventSource<boolean>; featureSwitchShowAllQuestions: UIEventSource<boolean>; osmConnection: OsmConnection; featureSwitchUserbadge: UIEventSource<boolean>; changes: Changes; layoutToUse: LayoutConfig; allElements: ElementStorage })
state: FeaturePipelineState)
: BaseUIElement {
let editElements: BaseUIElement[] = []
questionBoxes.forEach(questionBox => {

View file

@ -29,6 +29,7 @@ import Toggle from "../Input/Toggle";
import Img from "../Base/Img";
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
import Title from "../Base/Title";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
/**
* Shows the question element.
@ -118,24 +119,7 @@ export default class TagRenderingQuestion extends Combine {
if (options.bottomText !== undefined) {
bottomTags = options.bottomText(inputElement.GetValue())
} else {
bottomTags = new VariableUiElement(
inputElement.GetValue().map(
(tagsFilter: TagsFilter) => {
const csCount = state?.osmConnection?.userDetails?.data?.csCount ?? 1000;
if (csCount < Constants.userJourney.tagsVisibleAt) {
return "";
}
if (tagsFilter === undefined) {
return Translations.t.general.noTagsSelected.SetClass("subtle");
}
if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) {
const tagsStr = tagsFilter.asHumanString(false, true, tags.data);
return new FixedUiElement(tagsStr).SetClass("subtle");
}
return tagsFilter.asHumanString(true, true, tags.data);
}
)
).SetClass("block break-all")
bottomTags = TagRenderingQuestion.CreateTagExplanation(inputElement.GetValue(), tags, state)
}
super([
question,
@ -465,5 +449,28 @@ export default class TagRenderingQuestion extends Combine {
return inputTagsFilter;
}
public static CreateTagExplanation(selectedValue: UIEventSource<TagsFilter>,
tags: UIEventSource<object>,
state?: {osmConnection?: OsmConnection}){
return new VariableUiElement(
selectedValue.map(
(tagsFilter: TagsFilter) => {
const csCount = state?.osmConnection?.userDetails?.data?.csCount ?? Constants.userJourney.tagsVisibleAndWikiLinked + 1;
if (csCount < Constants.userJourney.tagsVisibleAt) {
return "";
}
if (tagsFilter === undefined) {
return Translations.t.general.noTagsSelected.SetClass("subtle");
}
if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) {
const tagsStr = tagsFilter.asHumanString(false, true, tags.data);
return new FixedUiElement(tagsStr).SetClass("subtle");
}
return tagsFilter.asHumanString(true, true, tags.data);
}
)
).SetClass("block break-all")
}
}

View file

@ -11,6 +11,8 @@
"delete": "Delete",
"explanations": {
"hardDelete": "This point will be deleted in OpenStreetMap. It can be recovered by an experienced contributor",
"retagNoOtherThemes": "This feature will be reclassified and hidden from this application",
"retagOtherThemes": "This feature will be retagged and visible in {othterThemes}",
"selectReason": "Please, select why this feature should be deleted",
"softDelete": "This feature will be updated and hidden from this application. <span class='subtle'>{reason}</span>"
},