import {UIEventSource} from "../Logic/UIEventSource"; import {And, Tag, TagsFilter, TagUtils} from "../Logic/Tags"; import Translations from "../UI/i18n/Translations"; import Locale from "../UI/i18n/Locale"; import {State} from "../State"; import Translation from "../UI/i18n/Translation"; import Combine from "../UI/Base/Combine"; import {TagDependantUIElement} from "../Customizations/UIElementConstructor"; import {UIElement} from "./UIElement"; import {VariableUiElement} from "./Base/VariableUIElement"; import InputElementMap from "./Input/InputElementMap"; import {CheckBoxes} from "./Input/Checkboxes"; import {InputElement} from "./Input/InputElement"; import {SaveButton} from "./SaveButton"; import {RadioButton} from "./Input/RadioButton"; import {FixedInputElement} from "./Input/FixedInputElement"; import {TextField, ValidatedTextField} from "./Input/TextField"; import {TagRenderingOptions} from "../Customizations/TagRenderingOptions"; import {FixedUiElement} from "./Base/FixedUiElement"; export class TagRendering extends UIElement implements TagDependantUIElement { private readonly _priority: number; private readonly _question: string | Translation; private readonly _mapping: { k: TagsFilter, txt: string | UIElement, priority?: number }[]; private currentTags: UIEventSource; private readonly _freeform: { key: string, template: string | UIElement, renderTemplate: string | Translation, placeholder?: string | UIElement, extraTags?: TagsFilter }; private readonly _questionElement: InputElement; private readonly _saveButton: UIElement; private readonly _friendlyLogin: UIElement; private readonly _skipButton: UIElement; private readonly _editButton: UIElement; private readonly _appliedTags: UIElement; private readonly _questionSkipped: UIEventSource = new UIEventSource(false); private readonly _editMode: UIEventSource = new UIEventSource(false); static injectFunction() { // This is a workaround as not to import tagrendering into TagREnderingOptions TagRenderingOptions.tagRendering = (tags, options) => new TagRendering(tags, options); return true; } constructor(tags: UIEventSource, options: { priority?: number question?: string | Translation, freeform?: { key: string, template: string | Translation, renderTemplate: string | Translation, placeholder?: string | Translation, extraTags?: TagsFilter, }, tagsPreprocessor?: ((tags: any) => any), multiAnswer?: boolean, mappings?: { k: TagsFilter, txt: string | Translation, priority?: number, substitute?: boolean, hideInAnswer?: boolean }[] }) { super(tags); this.ListenTo(Locale.language); this.ListenTo(this._questionSkipped); this.ListenTo(this._editMode); this.ListenTo(State.state?.osmConnection?.userDetails); const self = this; this._priority = options.priority ?? 0; this.currentTags = this._source.map(tags => { if (options.tagsPreprocessor === undefined) { return tags; } // we clone the tags... let newTags = {}; for (const k in tags) { newTags[k] = tags[k]; } // ... in order to safely edit them here options.tagsPreprocessor(newTags); return newTags; } ); if (options.question !== undefined) { this._question = options.question; } this._mapping = []; this._freeform = options.freeform; for (const choice of options.mappings ?? []) { let choiceSubbed = { k: choice.k?.substituteValues(this.currentTags.data), txt: choice.txt, priority: choice.priority } this._mapping.push({ k: choiceSubbed.k, txt: choiceSubbed.txt }); } // Prepare the actual input element -> pick an appropriate implementation this._questionElement = this.InputElementFor(options) ?? new FixedInputElement("No input possible", new Tag("a", "b")); const save = () => { const selection = self._questionElement.GetValue().data; console.log("Tagrendering: saving tags ", selection); if (selection) { State.state?.changes?.addTag(tags.data.id, selection); } self._editMode.setData(false); } this._appliedTags = new VariableUiElement( self._questionElement.GetValue().map( (tags: TagsFilter) => { if (tags === undefined) { return Translations.t.general.noTagsSelected.SetClass("subtle").Render(); } const csCount = State.state?.osmConnection?.userDetails?.data?.csCount ?? 1000; if (csCount < State.userJourney.tagsVisibleAt) { return ""; } if (csCount < State.userJourney.tagsVisibleAndWikiLinked) { const tagsStr = tags.asHumanString(false, true); return new FixedUiElement(tagsStr).SetClass("subtle").Render(); } return tags.asHumanString(true, true); } ) ).ListenTo(self._questionElement); const cancel = () => { self._questionSkipped.setData(true); self._editMode.setData(false); self._source.ping(); // Send a ping upstream to render the next question } // Setup the save button and it's action this._saveButton = new SaveButton(this._questionElement.GetValue()) .onClick(save); this._friendlyLogin = Translations.t.general.loginToStart .onClick(() => State.state.osmConnection.AttemptLogin()) this._editButton = new FixedUiElement(""); if (this._question !== undefined) { // 2.3em total width this._editButton = new FixedUiElement( "edit") .onClick(() => { self._editMode.setData(true); self._questionElement.GetValue().setData(self.CurrentValue()); }); } const cancelContents = this._editMode.map((isEditing) => { const tr = Translations.t.general; const text = isEditing ? tr.cancel : tr.skip; return text .SetStyle("display: inline-block;border: solid black 0.5px;padding: 0.2em 0.3em;border-radius: 1.5em;") .Render(); }, [Locale.language]); // And at last, set up the skip button this._skipButton = new VariableUiElement(cancelContents).onClick(cancel); } private InputElementFor(options: { freeform?: { key: string, template: string | Translation, renderTemplate: string | Translation, placeholder?: string | Translation, extraTags?: TagsFilter, }, multiAnswer?: boolean, mappings?: { k: TagsFilter, txt: string | Translation, priority?: number, substitute?: boolean, hideInAnswer?: boolean }[] }): InputElement { let freeformElement: TextField = undefined; if (options.freeform !== undefined) { freeformElement = this.InputForFreeForm(options.freeform); } if (options.mappings === undefined || options.mappings.length === 0) { return freeformElement; } const elements: InputElement[] = []; for (const mapping of options.mappings) { if (mapping.k === null) { continue; } if (mapping.hideInAnswer) { continue; } elements.push(this.InputElementForMapping(mapping, mapping.substitute)); } if (freeformElement !== undefined) { elements.push(freeformElement); } if (options.multiAnswer) { const possibleTags = elements.map(el => el.GetValue().data); const checkBoxes = new CheckBoxes(elements); const inputEl = new InputElementMap(checkBoxes, (t0, t1) => { return t0?.isEquivalent(t1) ?? false }, (indices) => { if (indices.length === 0) { return undefined; } let tags: TagsFilter[] = indices.map(i => elements[i].GetValue().data); return TagUtils.FlattenMultiAnswer(tags); }, (tags: TagsFilter) => { const splitUpValues = TagUtils.SplitMultiAnswer(tags, possibleTags, this._freeform?.key, this._freeform?.extraTags); const indices: number[] = [] for (let i = 0; i < splitUpValues.length; i++) { let splitUpValue = splitUpValues[i]; for (let j = 0; j < elements.length; j++) { let inputElement = elements[j]; if (inputElement.IsValid(splitUpValue)) { indices.push(j); inputElement.GetValue().setData(splitUpValue); break; } } } return indices; }, [freeformElement?.GetValue()] ); freeformElement?.GetValue()?.addCallbackAndRun(value => { const es = checkBoxes.GetValue(); const i = elements.length - 1; const index = es.data.indexOf(i); if (value === undefined) { if (index >= 0) { es.data.splice(index, 1); es.ping(); } } else if (index < 0) { es.data.push(i); es.ping(); } freeformElement.SetCursorPosition(-1); }); return inputEl; } return new RadioButton(elements, false); } private InputElementForMapping(mapping: { k: TagsFilter, txt: (string | Translation) }, substituteValues: boolean): FixedInputElement { if (substituteValues) { return new FixedInputElement(this.ApplyTemplate(mapping.txt), mapping.k.substituteValues(this.currentTags.data), (t0, t1) => t0.isEquivalent(t1) ); } return new FixedInputElement(this.ApplyTemplate(mapping.txt), mapping.k, (t0, t1) => t0.isEquivalent(t1)); } private InputForFreeForm(freeform : { key: string, template: string | Translation, renderTemplate: string | Translation, placeholder?: string | Translation, extraTags?: TagsFilter, }): TextField { if (freeform?.template === undefined) { return undefined; } const prepost = Translations.W(freeform.template).InnerRender() .replace("$$$", "$string$") .split("$"); const type = prepost[1]; let isValid = ValidatedTextField.inputValidation[type]; if (isValid === undefined) { isValid = () => true; } let formatter = ValidatedTextField.formatting[type] ?? ((str) => str); const pickString = (string: any) => { if (string === "" || string === undefined) { return undefined; } if (!isValid(string, this._source.data._country)) { return undefined; } const tag = new Tag(freeform.key, formatter(string, this._source.data._country)); if (tag.value.length > 255) { return undefined; // Too long } if (freeform.extraTags === undefined) { return tag; } return new And([ tag, freeform.extraTags ] ); }; const toString = (tag) => { if (tag instanceof And) { for (const subtag of tag.and) { if (subtag instanceof Tag && subtag.key === freeform.key) { return subtag.value; } } return undefined; } else if (tag instanceof Tag) { return tag.value } return undefined; } return new TextField({ placeholder: this._freeform.placeholder, fromString: pickString, toString: toString }); } IsKnown(): boolean { const tags = TagUtils.proprtiesToKV(this._source.data); for (const oneOnOneElement of this._mapping) { if (oneOnOneElement.k === null || oneOnOneElement.k === undefined || oneOnOneElement.k.matches(tags)) { return true; } } return this._freeform !== undefined && this._source.data[this._freeform.key] !== undefined; } IsSkipped(): boolean { return this._questionSkipped.data; } private CurrentValue(): TagsFilter { const tags = TagUtils.proprtiesToKV(this._source.data); for (const oneOnOneElement of this._mapping) { if (oneOnOneElement.k !== null && oneOnOneElement.k.matches(tags)) { return oneOnOneElement.k; } } if (this._freeform === undefined) { return undefined; } return new Tag(this._freeform.key, this._source.data[this._freeform.key]); } IsQuestioning(): boolean { if (this.IsKnown()) { return false; } if (this._question === undefined || this._question === "" || (this._freeform?.template === undefined && (this._mapping?.length ?? 0) == 0)) { // We don't ask this question in the first place return false; } if (this._questionSkipped.data) { // We don't ask for this question anymore, skipped by user return false; } return true; } private RenderAnswer(): UIElement { const tags = TagUtils.proprtiesToKV(this._source.data); let freeform: UIElement = new FixedUiElement(""); let freeformScore = -10; if (this._freeform !== undefined && this._source.data[this._freeform.key] !== undefined) { freeform = this.ApplyTemplate(this._freeform.renderTemplate); freeformScore = 0; } let highestScore = -100; let highestTemplate = undefined; for (const oneOnOneElement of this._mapping) { if (oneOnOneElement.k == null || oneOnOneElement.k.matches(tags)) { // We have found a matching key -> we use the template, but only if it scores better let score = oneOnOneElement.priority ?? (oneOnOneElement.k === null ? -1 : 0); if (score > highestScore) { highestScore = score; highestTemplate = oneOnOneElement.txt } } } if (freeformScore > highestScore) { return freeform; } if (highestTemplate !== undefined) { // we render the found template return this.ApplyTemplate(highestTemplate); } } InnerRender(): string { if (this.IsQuestioning() && (State.state !== undefined) // If State.state is undefined, we are testing/custom theme building -> show regular save && !State.state.osmConnection.userDetails.data.loggedIn) { const question = this.ApplyTemplate(this._question).SetClass('question-text'); return "
" + new Combine([ question.Render(), "
", this._questionElement, "", ]).Render() + "
"; } if (this.IsQuestioning() || this._editMode.data) { // Not yet known or questioning, we have to ask a question return "
" + new Combine([ "", this.ApplyTemplate(this._question), "", "
", "
", this._questionElement , "
", this._skipButton, this._saveButton, "
", this._appliedTags ]).Render() + "
" } if (this.IsKnown()) { const answer = this.RenderAnswer(); if (answer.IsEmpty()) { return ""; } const answerStyle = " display: inline-block;" + " margin: 0.1em;" + " width: 100%;" + " font-size: large;" if (State.state === undefined || // state undefined -> we are custom testing State.state?.osmConnection?.userDetails?.data?.loggedIn && this._question !== undefined) { answer.SetStyle("display:inline-block;width:calc(100% - 2.3em);") return new Combine([ answer, this._editButton]) .SetStyle(answerStyle) .Render(); } return answer.SetStyle(answerStyle).Render(); } return ""; } Priority(): number { return this._priority; } private ApplyTemplate(template: string | Translation): UIElement { if (template === undefined || template === null) { return undefined; } return new VariableUiElement(this.currentTags.map(tags => { const tr = Translations.WT(template); if (tr.Subs === undefined) { // This is a weird edge case return tr.InnerRender(); } return tr.Subs(tags).InnerRender() })).ListenTo(Locale.language); } }