forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			427 lines
		
	
	
		
			No EOL
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			427 lines
		
	
	
		
			No EOL
		
	
	
		
			14 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {UIElement} from "../UI/UIElement";
 | 
						|
import {UIEventSource} from "../Logic/UIEventSource";
 | 
						|
import {And, Tag, TagsFilter, TagUtils} from "../Logic/TagsFilter";
 | 
						|
import {FixedUiElement} from "../UI/Base/FixedUiElement";
 | 
						|
import {SaveButton} from "../UI/SaveButton";
 | 
						|
import {VariableUiElement} from "../UI/Base/VariableUIElement";
 | 
						|
import {TagDependantUIElement} from "./UIElementConstructor";
 | 
						|
import {TextField, ValidatedTextField} from "../UI/Input/TextField";
 | 
						|
import {InputElement} from "../UI/Input/InputElement";
 | 
						|
import {InputElementWrapper} from "../UI/Input/InputElementWrapper";
 | 
						|
import {FixedInputElement} from "../UI/Input/FixedInputElement";
 | 
						|
import {RadioButton} from "../UI/Input/RadioButton";
 | 
						|
import Translations from "../UI/i18n/Translations";
 | 
						|
import Locale from "../UI/i18n/Locale";
 | 
						|
import {State} from "../State";
 | 
						|
import {TagRenderingOptions} from "./TagRenderingOptions";
 | 
						|
import Translation from "../UI/i18n/Translation";
 | 
						|
 | 
						|
 | 
						|
export class TagRendering extends UIElement implements TagDependantUIElement {
 | 
						|
 | 
						|
 | 
						|
    private _priority: number;
 | 
						|
 | 
						|
 | 
						|
    private _question: Translation;
 | 
						|
    private _mapping: { k: TagsFilter, txt: string | Translation, priority?: number }[];
 | 
						|
 | 
						|
    private _tagsPreprocessor?: ((tags: any) => any);
 | 
						|
    private _freeform: {
 | 
						|
        key: string,
 | 
						|
        template: string | Translation,
 | 
						|
        renderTemplate: string | Translation,
 | 
						|
        placeholder?: string | Translation,
 | 
						|
        extraTags?: TagsFilter
 | 
						|
    };
 | 
						|
 | 
						|
 | 
						|
    private readonly _questionElement: InputElement<TagsFilter>;
 | 
						|
 | 
						|
    private readonly _saveButton: UIElement;
 | 
						|
    private readonly _skipButton: UIElement;
 | 
						|
    private readonly _editButton: UIElement;
 | 
						|
 | 
						|
    private readonly _questionSkipped: UIEventSource<boolean> = new UIEventSource<boolean>(false);
 | 
						|
 | 
						|
    private readonly _editMode: UIEventSource<boolean> = new UIEventSource<boolean>(false);
 | 
						|
 | 
						|
 | 
						|
    private static injected = TagRendering.injectFunction(); 
 | 
						|
    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<any>, options: {
 | 
						|
        priority?: number
 | 
						|
 | 
						|
        question?: string | Translation,
 | 
						|
 | 
						|
        freeform?: {
 | 
						|
            key: string,
 | 
						|
            template: string | Translation,
 | 
						|
            renderTemplate: string | Translation,
 | 
						|
            placeholder?: string | Translation,
 | 
						|
            extraTags?: TagsFilter,
 | 
						|
        },
 | 
						|
        tagsPreprocessor?: ((tags: any) => any),
 | 
						|
        mappings?: { k: TagsFilter, txt: string | Translation, priority?: number, substitute?: boolean }[]
 | 
						|
    }) {
 | 
						|
        super(tags);
 | 
						|
        this.ListenTo(Locale.language);
 | 
						|
        this.ListenTo(this._questionSkipped);
 | 
						|
        this.ListenTo(this._editMode);
 | 
						|
        this.ListenTo(State.state?.osmConnection?.userDetails);
 | 
						|
 | 
						|
        console.log("Creating tagRendering with", options)
 | 
						|
 | 
						|
        const self = this;
 | 
						|
       
 | 
						|
        this._priority = options.priority ?? 0;
 | 
						|
        this._tagsPreprocessor = function (properties) {
 | 
						|
            if (options.tagsPreprocessor === undefined) {
 | 
						|
                return properties;
 | 
						|
            }
 | 
						|
            const newTags = {};
 | 
						|
            for (const k in properties) {
 | 
						|
                newTags[k] = properties[k];
 | 
						|
            }
 | 
						|
            options.tagsPreprocessor(newTags);
 | 
						|
            return newTags;
 | 
						|
        };
 | 
						|
 | 
						|
        if (options.question !== undefined) {
 | 
						|
            this._question = this.ApplyTemplate(options.question);
 | 
						|
        }
 | 
						|
        
 | 
						|
        this._mapping = [];
 | 
						|
        this._freeform = options.freeform;
 | 
						|
 | 
						|
 | 
						|
        for (const choice of options.mappings ?? []) {
 | 
						|
            let choiceSubbed = {
 | 
						|
                k: choice.k,
 | 
						|
                txt: choice.txt,
 | 
						|
                priority: choice.priority
 | 
						|
            };
 | 
						|
 | 
						|
            if (choice.substitute) {
 | 
						|
                const newTags = this._tagsPreprocessor(this._source.data);
 | 
						|
                choiceSubbed = {
 | 
						|
                    k: choice.k.substituteValues(newTags),
 | 
						|
                    txt: this.ApplyTemplate(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);
 | 
						|
        const save = () => {
 | 
						|
            const selection = self._questionElement.GetValue().data;
 | 
						|
            if (selection) {
 | 
						|
                State.state.changes.addTag(tags.data.id, selection);
 | 
						|
            }
 | 
						|
            self._editMode.setData(false);
 | 
						|
        }
 | 
						|
 | 
						|
        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._editButton = new FixedUiElement("");
 | 
						|
        if (this._question !== undefined) {
 | 
						|
            this._editButton = new FixedUiElement("<img class='editbutton' src='./assets/pencil.svg' alt='edit'>")
 | 
						|
                .onClick(() => {
 | 
						|
                    self._editMode.setData(true);
 | 
						|
                    self._questionElement.GetValue().setData(self.CurrentValue());
 | 
						|
                });
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        const cancelContents = this._editMode.map((isEditing) => {
 | 
						|
            if (isEditing) {
 | 
						|
                return "<span class='skip-button'>"+Translations.t.general.cancel.R()+"</span>";
 | 
						|
            } else {
 | 
						|
                return "<span class='skip-button'>"+Translations.t.general.skip.R()+"</span>";
 | 
						|
            }
 | 
						|
        }, [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,
 | 
						|
        },
 | 
						|
        mappings?: { k: TagsFilter, txt: string | Translation, priority?: number, substitute?: boolean }[]
 | 
						|
    }):
 | 
						|
        InputElement<TagsFilter> {
 | 
						|
 | 
						|
        const elements = [];
 | 
						|
 | 
						|
        if (options.mappings !== undefined) {
 | 
						|
            
 | 
						|
            const previousTexts= [];
 | 
						|
            for (const mapping of options.mappings) {
 | 
						|
                if(mapping.k === null){
 | 
						|
                    continue;
 | 
						|
                }
 | 
						|
                if(previousTexts.indexOf(mapping.txt) >= 0){
 | 
						|
                    continue;
 | 
						|
                }
 | 
						|
                previousTexts.push(this.ApplyTemplate(mapping.txt));
 | 
						|
                
 | 
						|
                elements.push(this.InputElementForMapping(mapping));
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        if (options.freeform !== undefined) {
 | 
						|
            elements.push(this.InputForFreeForm(options.freeform));
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        if (elements.length == 0) {
 | 
						|
            console.warn("WARNING: no tagrendering with following options:", options);
 | 
						|
            return new FixedInputElement("This should not happen: no tag renderings defined", undefined);
 | 
						|
        }
 | 
						|
        if (elements.length == 1) {
 | 
						|
            return elements[0];
 | 
						|
        }
 | 
						|
 | 
						|
        return new RadioButton(elements, false);
 | 
						|
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    private InputElementForMapping(mapping: { k: TagsFilter, txt: string | UIElement }) {
 | 
						|
        return new FixedInputElement(mapping.txt, mapping.k);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    private InputForFreeForm(freeform): InputElement<TagsFilter> {
 | 
						|
        if (freeform === 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 = (str) => 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 (freeform.extraTags === undefined) {
 | 
						|
                    return tag;
 | 
						|
                }
 | 
						|
                return new And([
 | 
						|
                        tag,
 | 
						|
                        freeform.extraTags
 | 
						|
                    ]
 | 
						|
                );
 | 
						|
            };
 | 
						|
 | 
						|
        const toString =
 | 
						|
            (tag) => {
 | 
						|
                if (tag instanceof And) {
 | 
						|
                    return toString(tag.and[0])
 | 
						|
                } else if (tag instanceof Tag) {
 | 
						|
                    return tag.value
 | 
						|
                }
 | 
						|
                return undefined;
 | 
						|
            }
 | 
						|
 | 
						|
 | 
						|
        let inputElement: InputElement<TagsFilter>;
 | 
						|
        const textField = new TextField({
 | 
						|
            placeholder: this._freeform.placeholder,
 | 
						|
            fromString: pickString,
 | 
						|
            toString: toString
 | 
						|
        });
 | 
						|
 | 
						|
        const pre = prepost[0] !== undefined ? this.ApplyTemplate(prepost[0]) : "";
 | 
						|
        const post = prepost[2] !== undefined ? this.ApplyTemplate(prepost[2]) : "";
 | 
						|
 | 
						|
        return new InputElementWrapper(pre, textField, post);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    IsKnown(): boolean {
 | 
						|
        const tags = TagUtils.proprtiesToKV(this._source.data);
 | 
						|
 | 
						|
        for (const oneOnOneElement of this._mapping) {
 | 
						|
            if (oneOnOneElement.k === null || 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) {
 | 
						|
            // 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() || this._editMode.data) {
 | 
						|
            // Not yet known or questioning, we have to ask a question
 | 
						|
 | 
						|
            const question =
 | 
						|
                this.ApplyTemplate(this._question).Render();
 | 
						|
 | 
						|
            return "<div class='question'>" +
 | 
						|
                "<span class='question-text'>" + question + "</span>" +
 | 
						|
                (this._question.IsEmpty() ? "" : "<br/>") +
 | 
						|
                "<div>" + this._questionElement.Render() + "</div>" +
 | 
						|
                this._skipButton.Render() +
 | 
						|
                this._saveButton.Render() +
 | 
						|
                "</div>"
 | 
						|
        }
 | 
						|
 | 
						|
        if (this.IsKnown()) {
 | 
						|
            const html = this.RenderAnswer().Render();
 | 
						|
            if (html === "") {
 | 
						|
                return "";
 | 
						|
            }
 | 
						|
 | 
						|
 | 
						|
            let editButton = "";
 | 
						|
            if (State.state?.osmConnection?.userDetails?.data?.loggedIn && this._question !== undefined) {
 | 
						|
                editButton = this._editButton.Render();
 | 
						|
            }
 | 
						|
 | 
						|
            return "<span class='answer'>" +
 | 
						|
                "<span class='answer-text'>" + html + "</span>" +
 | 
						|
                editButton +
 | 
						|
                "</span>";
 | 
						|
        }
 | 
						|
 | 
						|
        return "";
 | 
						|
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    Priority(): number {
 | 
						|
        return this._priority;
 | 
						|
    }
 | 
						|
 | 
						|
    private ApplyTemplate(template: string | Translation): Translation {
 | 
						|
        if (template === undefined || template === null) {
 | 
						|
            throw "Trying to apply a template, but the template is null/undefined"
 | 
						|
        }
 | 
						|
 | 
						|
        if (typeof (template) === "string") {
 | 
						|
            const tags = this._tagsPreprocessor(this._source.data);
 | 
						|
            return new Translation ({en:TagUtils.ApplyTemplate(template, tags)});
 | 
						|
        }
 | 
						|
        const tags = this._tagsPreprocessor(this._source.data);
 | 
						|
 | 
						|
        return template.Subs(tags);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
 
 | 
						|
} |