forked from MapComplete/MapComplete
First draft of an element which deletes points
This commit is contained in:
parent
bbfcee686f
commit
5d3365afb8
13 changed files with 407 additions and 169 deletions
|
@ -1,72 +0,0 @@
|
|||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import State from "../../State";
|
||||
import Toggle from "../Input/Toggle";
|
||||
import Translations from "../i18n/Translations";
|
||||
import {SubtleButton} from "../Base/SubtleButton";
|
||||
import Svg from "../../Svg";
|
||||
import DeleteAction from "../../Logic/Osm/DeleteAction";
|
||||
import {Tag} from "../../Logic/Tags/Tag";
|
||||
import CheckBoxes from "../Input/Checkboxes";
|
||||
import {RadioButton} from "../Input/RadioButton";
|
||||
import {FixedInputElement} from "../Input/FixedInputElement";
|
||||
import {TextField} from "../Input/TextField";
|
||||
|
||||
|
||||
export default class DeleteWizard extends Toggle {
|
||||
/**
|
||||
* The UI-element which triggers 'deletion' (either soft or hard).
|
||||
*
|
||||
* - A 'hard deletion' is if the point is actually deleted from the OSM database
|
||||
* - A 'soft deletion' is if the point is not deleted, but the tagging is modified which will result in the point not being picked up by the filters anymore.
|
||||
* Apart having needing theme-specific tags added (which must be supplied by the theme creator), fixme='marked for deletion' will be added too
|
||||
*
|
||||
* A deletion is only possible if the user is logged in.
|
||||
* A soft deletion is only possible if tags are provided
|
||||
* A hard deletion is only possible if the user has sufficient rigts
|
||||
*
|
||||
* If no deletion is possible at all, the delete button will not be shown - but a reason will be shown instead.
|
||||
*
|
||||
* @param id: The id of the element to remove
|
||||
* @param 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, softDeletionTags? : Tag[]) {
|
||||
const t = Translations.t.delete
|
||||
|
||||
const deleteAction = new DeleteAction(id);
|
||||
|
||||
const deleteReasons = new RadioButton<string>(
|
||||
[new FixedInputElement(
|
||||
t.reasons.test, "test"
|
||||
),
|
||||
new FixedInputElement(t.reasons.disused, "disused"),
|
||||
new FixedInputElement(t.reasons.notFound, "not found"),
|
||||
new TextField()]
|
||||
|
||||
)
|
||||
|
||||
const deleteButton = new SubtleButton(
|
||||
Svg.delete_icon_svg(),
|
||||
t.delete.Clone()
|
||||
).onClick(() => deleteAction.DoDelete(deleteReasons.GetValue().data))
|
||||
|
||||
|
||||
|
||||
|
||||
super(
|
||||
|
||||
|
||||
|
||||
new Toggle(
|
||||
deleteButton,
|
||||
new VariableUiElement(deleteAction.canBeDeleted.map(cbd => cbd.reason.Clone())),
|
||||
deleteAction.canBeDeleted.map(cbd => cbd.canBeDeleted)
|
||||
),
|
||||
|
||||
|
||||
|
||||
t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin),
|
||||
State.state.osmConnection.isLoggedIn
|
||||
)
|
||||
|
||||
}
|
||||
}
|
244
UI/Popup/DeleteWizard.ts
Normal file
244
UI/Popup/DeleteWizard.ts
Normal file
|
@ -0,0 +1,244 @@
|
|||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import State from "../../State";
|
||||
import Toggle from "../Input/Toggle";
|
||||
import Translations from "../i18n/Translations";
|
||||
import Svg from "../../Svg";
|
||||
import DeleteAction from "../../Logic/Osm/DeleteAction";
|
||||
import {Tag} from "../../Logic/Tags/Tag";
|
||||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
|
||||
import TagRenderingQuestion from "./TagRenderingQuestion";
|
||||
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
|
||||
import Combine from "../Base/Combine";
|
||||
import {SubtleButton} from "../Base/SubtleButton";
|
||||
import {FixedUiElement} from "../Base/FixedUiElement";
|
||||
import {Translation} from "../i18n/Translation";
|
||||
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
|
||||
import BaseUIElement from "../BaseUIElement";
|
||||
import {Changes} from "../../Logic/Osm/Changes";
|
||||
import {And} from "../../Logic/Tags/And";
|
||||
|
||||
|
||||
export default class DeleteWizard extends Toggle {
|
||||
/**
|
||||
* The UI-element which triggers 'deletion' (either soft or hard).
|
||||
*
|
||||
* - A 'hard deletion' is if the point is actually deleted from the OSM database
|
||||
* - A 'soft deletion' is if the point is not deleted, but the tagging is modified which will result in the point not being picked up by the filters anymore.
|
||||
* Apart having needing theme-specific tags added (which must be supplied by the theme creator), fixme='marked for deletion' will be added too
|
||||
*
|
||||
* A deletion is only possible if the user is logged in.
|
||||
* A soft deletion is only possible if tags are provided
|
||||
* A hard deletion is only possible if the user has sufficient rigts
|
||||
*
|
||||
* There is also the possibility to have a 'trojan horse' option. If the user selects that option, it is NEVER removed, but the tags are applied.
|
||||
* Ideal for the case of "THIS PATH IS ON MY GROUND AND SHOULD BE DELETED IMMEDIATELY OR I WILL GET MY LAWYER" but to mark it as private instead.
|
||||
* (Note that _delete_reason is used as trigger to do actual deletion - setting such a tag WILL delete from the database with that as changeset comment)
|
||||
*
|
||||
* @param id: The id of the element to remove
|
||||
* @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,
|
||||
options?: {
|
||||
noDeleteOptions?: { if: Tag[], then: Translation }[]
|
||||
softDeletionTags?: Tag[]
|
||||
}) {
|
||||
|
||||
options = options ?? {}
|
||||
const deleteAction = new DeleteAction(id);
|
||||
const tagsSource = State.state.allElements.getEventSourceById(id)
|
||||
|
||||
let softDeletionTags = options.softDeletionTags ?? []
|
||||
const allowSoftDeletion = softDeletionTags.length > 0
|
||||
|
||||
const confirm = new UIEventSource<boolean>(false)
|
||||
|
||||
|
||||
function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
|
||||
if (reason !== undefined) {
|
||||
tagsToApply.splice(0, 0, {
|
||||
k: "fixme",
|
||||
v: `A mapcomplete user marked this feature to be deleted (${reason})`
|
||||
})
|
||||
}
|
||||
(State.state?.changes ?? new Changes())
|
||||
.addTag(id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource);
|
||||
}
|
||||
|
||||
function doDelete(selected: TagsFilter) {
|
||||
const tgs = selected.asChange(tagsSource.data)
|
||||
const deleteReasonMatch = tgs.filter(kv => kv.k === "_delete_reason")
|
||||
if (deleteReasonMatch.length > 0) {
|
||||
// We should actually delete!
|
||||
const deleteReason = deleteReasonMatch[0].v
|
||||
deleteAction.DoDelete(deleteReason, () => {
|
||||
// The user doesn't have sufficient permissions to _actually_ delete the feature
|
||||
// We 'soft delete' instead (and add a fixme)
|
||||
softDelete(deleteReason, tgs.filter(kv => kv.k !== "_delete_reason"))
|
||||
|
||||
});
|
||||
return
|
||||
} else {
|
||||
// This is an injected tagging
|
||||
softDelete(undefined, tgs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
const t = Translations.t.delete
|
||||
const cancelButton = t.cancel.Clone().SetClass("block btn btn-secondary").onClick(() => confirm.setData(false));
|
||||
const config = DeleteWizard.generateDeleteTagRenderingConfig(softDeletionTags, options.noDeleteOptions)
|
||||
const question = new TagRenderingQuestion(
|
||||
tagsSource,
|
||||
config,
|
||||
{
|
||||
cancelButton: 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, deleteAction)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The button which is shown first. Opening it will trigger the check for deletions
|
||||
*/
|
||||
const deleteButton = new SubtleButton(Svg.delete_icon_svg(), t.delete).onClick(
|
||||
() => {
|
||||
deleteAction.CheckDeleteability(true)
|
||||
confirm.setData(true);
|
||||
}
|
||||
);
|
||||
|
||||
super(
|
||||
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"),
|
||||
new Toggle(
|
||||
new Toggle(
|
||||
new Toggle(
|
||||
question,
|
||||
|
||||
deleteButton,
|
||||
confirm),
|
||||
new VariableUiElement(deleteAction.canBeDeleted.map(cbd => new Combine([cbd.reason.Clone(), t.useSomethingElse]))),
|
||||
deleteAction.canBeDeleted.map(cbd => allowSoftDeletion || cbd.canBeDeleted !== false)),
|
||||
t.loginToDelete.Clone().onClick(State.state.osmConnection.AttemptLogin),
|
||||
State.state.osmConnection.isLoggedIn
|
||||
),
|
||||
deleteAction.isDeleted)
|
||||
|
||||
}
|
||||
|
||||
|
||||
private static constructConfirmButton(deleteReasons: UIEventSource<TagsFilter>): 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()
|
||||
]).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()
|
||||
]).SetClass("flex btn btn-disabled bg-red-200")
|
||||
|
||||
return new Toggle(
|
||||
btn,
|
||||
btnNonActive,
|
||||
deleteReasons.map(reason => reason !== undefined)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
private static constructExplanation(tags: UIEventSource<TagsFilter>, deleteAction: DeleteAction) {
|
||||
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");
|
||||
}
|
||||
|
||||
const hasDeletionTag = currentTags.asChange(currentTags).some(kv => kv.k === "_delete_reason")
|
||||
|
||||
if (cbd.canBeDeleted && hasDeletionTag) {
|
||||
return t.explanations.hardDelete.Clone()
|
||||
}
|
||||
return new Combine([t.explanations.softDelete.Subs({reason: cbd.reason}),
|
||||
new FixedUiElement(currentTags.asHumanString(false, true, currentTags)).SetClass("subtle")
|
||||
]).SetClass("flex flex-col")
|
||||
|
||||
|
||||
}
|
||||
, [deleteAction.canBeDeleted]
|
||||
)).SetClass("block")
|
||||
}
|
||||
|
||||
private static generateDeleteTagRenderingConfig(softDeletionTags: Tag[], nonDeleteOptions: {
|
||||
if: Tag[],
|
||||
then: Translation
|
||||
}[]) {
|
||||
const t = Translations.t.delete
|
||||
nonDeleteOptions = nonDeleteOptions ?? []
|
||||
const softDeletionTagsStr = (softDeletionTags ?? []).map(t => t.asHumanString(false, false))
|
||||
const nonDeleteOptionsStr: { if: AndOrTagConfigJson, then: any }[] = []
|
||||
for (const nonDeleteOption of nonDeleteOptions) {
|
||||
const newIf: string[] = nonDeleteOption.if.map(tag => tag.asHumanString())
|
||||
|
||||
nonDeleteOptionsStr.push({
|
||||
if: {and: newIf},
|
||||
then: nonDeleteOption.then
|
||||
})
|
||||
}
|
||||
|
||||
return new TagRenderingConfig(
|
||||
{
|
||||
question: t.whyDelete,
|
||||
render: "Deleted because {_delete_reason}",
|
||||
freeform: {
|
||||
key: "_delete_reason",
|
||||
addExtraTags: softDeletionTagsStr
|
||||
},
|
||||
mappings: [
|
||||
|
||||
...nonDeleteOptionsStr,
|
||||
|
||||
{
|
||||
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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
}, undefined, "Delete wizard"
|
||||
)
|
||||
}
|
||||
|
||||
}
|
|
@ -43,11 +43,14 @@ export default class EditableTagRendering extends Toggle {
|
|||
editMode.setData(false)
|
||||
});
|
||||
|
||||
const question = new TagRenderingQuestion(tags, configuration,units,
|
||||
() => {
|
||||
editMode.setData(false)
|
||||
},
|
||||
cancelbutton)
|
||||
const question = new TagRenderingQuestion(tags, configuration,
|
||||
{
|
||||
units: units,
|
||||
cancelButton: cancelbutton,
|
||||
afterSave: () => {
|
||||
editMode.setData(false)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
rendering = new Toggle(
|
||||
|
|
|
@ -27,17 +27,20 @@ export default class QuestionBox extends VariableUiElement {
|
|||
}
|
||||
|
||||
const tagRenderingQuestions = tagRenderings
|
||||
.map((tagRendering, i) => new TagRenderingQuestion(tagsSource, tagRendering, units,
|
||||
() => {
|
||||
// We save
|
||||
skippedQuestions.ping();
|
||||
},
|
||||
Translations.t.general.skip.Clone()
|
||||
.SetClass("btn btn-secondary mr-3")
|
||||
.onClick(() => {
|
||||
skippedQuestions.data.push(i);
|
||||
.map((tagRendering, i) => new TagRenderingQuestion(tagsSource, tagRendering,
|
||||
{
|
||||
units: units,
|
||||
afterSave: () => {
|
||||
// We save
|
||||
skippedQuestions.ping();
|
||||
})
|
||||
},
|
||||
cancelButton: Translations.t.general.skip.Clone()
|
||||
.SetClass("btn btn-secondary mr-3")
|
||||
.onClick(() => {
|
||||
skippedQuestions.data.push(i);
|
||||
skippedQuestions.ping();
|
||||
})
|
||||
}
|
||||
));
|
||||
|
||||
const skippedQuestionsButton = Translations.t.general.skippedQuestions.Clone()
|
||||
|
|
|
@ -17,16 +17,16 @@ export class SaveButton extends Toggle {
|
|||
|
||||
const isSaveable = value.map(v => v !== false && (v ?? "") !== "")
|
||||
|
||||
|
||||
const saveEnabled = Translations.t.general.save.Clone().SetClass(`btn`);
|
||||
const saveDisabled = Translations.t.general.save.Clone().SetClass(`btn btn-disabled`);
|
||||
const text = Translations.t.general.save
|
||||
const saveEnabled = text.Clone().SetClass(`btn`);
|
||||
const saveDisabled = text.SetClass(`btn btn-disabled`);
|
||||
const save = new Toggle(
|
||||
saveEnabled,
|
||||
saveDisabled,
|
||||
isSaveable
|
||||
)
|
||||
super(
|
||||
save,
|
||||
save,
|
||||
pleaseLogin,
|
||||
osmConnection?.isLoggedIn ?? new UIEventSource<any>(false)
|
||||
)
|
||||
|
|
|
@ -30,23 +30,27 @@ import {Unit} from "../../Customizations/JSON/Denomination";
|
|||
* Note that the value _migh_ already be known, e.g. when selected or when changing the value
|
||||
*/
|
||||
export default class TagRenderingQuestion extends Combine {
|
||||
|
||||
|
||||
constructor(tags: UIEventSource<any>,
|
||||
configuration: TagRenderingConfig,
|
||||
units: Unit[],
|
||||
afterSave?: () => void,
|
||||
cancelButton?: BaseUIElement,
|
||||
options?: {
|
||||
units?: Unit[],
|
||||
afterSave?: () => void,
|
||||
cancelButton?: BaseUIElement,
|
||||
saveButtonConstr?: (src: UIEventSource<TagsFilter>) => BaseUIElement,
|
||||
bottomText?: (src: UIEventSource<TagsFilter>) => BaseUIElement
|
||||
}
|
||||
) {
|
||||
if (configuration === undefined) {
|
||||
throw "A question is needed for a question visualization"
|
||||
}
|
||||
const applicableUnit = (units ?? []).filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0];
|
||||
options = options ?? {}
|
||||
const applicableUnit = (options.units ?? []).filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0];
|
||||
const question = new SubstitutedTranslation(configuration.question, tags)
|
||||
.SetClass("question-text");
|
||||
|
||||
|
||||
|
||||
const inputElement = TagRenderingQuestion.GenerateInputElement(configuration, applicableUnit, tags)
|
||||
const inputElement: InputElement<TagsFilter> = TagRenderingQuestion.GenerateInputElement(configuration, applicableUnit, tags)
|
||||
const save = () => {
|
||||
const selection = inputElement.GetValue().data;
|
||||
console.log("Save button clicked, the tags are is", selection)
|
||||
|
@ -55,48 +59,54 @@ export default class TagRenderingQuestion extends Combine {
|
|||
.addTag(tags.data.id, selection, tags);
|
||||
}
|
||||
|
||||
if (afterSave) {
|
||||
afterSave();
|
||||
if (options.afterSave) {
|
||||
options.afterSave();
|
||||
}
|
||||
}
|
||||
|
||||
if (options.saveButtonConstr === undefined) {
|
||||
options.saveButtonConstr = v => new SaveButton(v,
|
||||
State.state?.osmConnection)
|
||||
.onClick(save)
|
||||
}
|
||||
|
||||
const saveButton = new SaveButton(inputElement.GetValue(),
|
||||
State.state?.osmConnection)
|
||||
.onClick(save)
|
||||
const saveButton = options.saveButtonConstr(inputElement.GetValue())
|
||||
|
||||
|
||||
const appliedTags = new VariableUiElement(
|
||||
inputElement.GetValue().map(
|
||||
(tagsFilter: TagsFilter) => {
|
||||
const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000;
|
||||
if (csCount < Constants.userJourney.tagsVisibleAt) {
|
||||
return "";
|
||||
let bottomTags: BaseUIElement;
|
||||
if (options.bottomText !== undefined) {
|
||||
bottomTags = options.bottomText(inputElement.GetValue())
|
||||
} else {
|
||||
bottomTags = new VariableUiElement(
|
||||
inputElement.GetValue().map(
|
||||
(tagsFilter: TagsFilter) => {
|
||||
const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000;
|
||||
if (csCount < Constants.userJourney.tagsVisibleAt) {
|
||||
return "";
|
||||
}
|
||||
if (tagsFilter === undefined) {
|
||||
return Translations.t.general.noTagsSelected.Clone().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);
|
||||
}
|
||||
if (tagsFilter === undefined) {
|
||||
return Translations.t.general.noTagsSelected.Clone().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")
|
||||
|
||||
super ([
|
||||
)
|
||||
).SetClass("block break-all")
|
||||
}
|
||||
super([
|
||||
question,
|
||||
inputElement,
|
||||
cancelButton,
|
||||
inputElement,
|
||||
options.cancelButton,
|
||||
saveButton,
|
||||
appliedTags]
|
||||
bottomTags]
|
||||
)
|
||||
this .SetClass("question")
|
||||
this.SetClass("question")
|
||||
|
||||
}
|
||||
|
||||
private static GenerateInputElement(configuration: TagRenderingConfig, applicableUnit: Unit, tagsSource: UIEventSource< any>): InputElement<TagsFilter> {
|
||||
private static GenerateInputElement(configuration: TagRenderingConfig, applicableUnit: Unit, tagsSource: UIEventSource<any>): InputElement<TagsFilter> {
|
||||
let inputEls: InputElement<TagsFilter>[];
|
||||
|
||||
const mappings = (configuration.mappings ?? [])
|
||||
|
@ -105,7 +115,7 @@ export default class TagRenderingQuestion extends Combine {
|
|||
return false;
|
||||
}
|
||||
return !(typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(tagsSource.data));
|
||||
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
@ -255,10 +265,10 @@ export default class TagRenderingQuestion extends Combine {
|
|||
private static GenerateMappingElement(
|
||||
tagsSource: UIEventSource<any>,
|
||||
mapping: {
|
||||
if: TagsFilter,
|
||||
then: Translation,
|
||||
hideInAnswer: boolean | TagsFilter
|
||||
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
|
||||
if: TagsFilter,
|
||||
then: Translation,
|
||||
hideInAnswer: boolean | TagsFilter
|
||||
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
|
||||
|
||||
let tagging = mapping.if;
|
||||
if (ifNot.length > 0) {
|
||||
|
|
|
@ -30,6 +30,9 @@ export default class Translations {
|
|||
console.error(msg, t);
|
||||
throw msg
|
||||
}
|
||||
if(t instanceof Translation){
|
||||
return t;
|
||||
}
|
||||
return new Translation(t, context);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue