forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			321 lines
		
	
	
		
			No EOL
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			321 lines
		
	
	
		
			No EOL
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import {DropDown} from "./DropDown";
 | 
						|
import * as EmailValidator from "email-validator";
 | 
						|
import {parsePhoneNumberFromString} from "libphonenumber-js";
 | 
						|
import InputElementMap from "./InputElementMap";
 | 
						|
import {InputElement} from "./InputElement";
 | 
						|
import {TextField} from "./TextField";
 | 
						|
import {UIElement} from "../UIElement";
 | 
						|
import {UIEventSource} from "../../Logic/UIEventSource";
 | 
						|
import CombinedInputElement from "./CombinedInputElement";
 | 
						|
import SimpleDatePicker from "./SimpleDatePicker";
 | 
						|
import OpeningHoursInput from "./OpeningHours/OpeningHoursInput";
 | 
						|
import DirectionInput from "./DirectionInput";
 | 
						|
 | 
						|
interface TextFieldDef {
 | 
						|
    name: string,
 | 
						|
    explanation: string,
 | 
						|
    isValid: ((s: string, country?:() => string) => boolean),
 | 
						|
    reformat?: ((s: string, country?: () => string) => string),
 | 
						|
    inputHelper?: (value: UIEventSource<string>, options?: {
 | 
						|
        location: [number, number]
 | 
						|
    }) => InputElement<string>,
 | 
						|
}
 | 
						|
 | 
						|
export default class ValidatedTextField {
 | 
						|
 | 
						|
 | 
						|
    private static tp(name: string,
 | 
						|
                      explanation: string,
 | 
						|
                      isValid?: ((s: string, country?: () => string) => boolean),
 | 
						|
                      reformat?: ((s: string, country?: () => string) => string),
 | 
						|
                      inputHelper?: (value: UIEventSource<string>, options?:{
 | 
						|
                          location: [number, number]
 | 
						|
                      }) => InputElement<string>): TextFieldDef {
 | 
						|
 | 
						|
        if (isValid === undefined) {
 | 
						|
            isValid = () => true;
 | 
						|
        }
 | 
						|
 | 
						|
        if (reformat === undefined) {
 | 
						|
            reformat = (str, _) => str;
 | 
						|
        }
 | 
						|
 | 
						|
 | 
						|
        return {
 | 
						|
            name: name,
 | 
						|
            explanation: explanation,
 | 
						|
            isValid: isValid,
 | 
						|
            reformat: reformat,
 | 
						|
            inputHelper: inputHelper
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public static tpList: TextFieldDef[] = [
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "string",
 | 
						|
            "A basic string"),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "text",
 | 
						|
            "A string, but allows input of longer strings more comfortably (a text area)"),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "date",
 | 
						|
            "A date",
 | 
						|
            (str) => {
 | 
						|
                const time = Date.parse(str);
 | 
						|
                return !isNaN(time);
 | 
						|
            },
 | 
						|
            (str) => {
 | 
						|
                const d = new Date(str);
 | 
						|
                let month = '' + (d.getMonth() + 1);
 | 
						|
                let day = '' + d.getDate();
 | 
						|
                const year = d.getFullYear();
 | 
						|
 | 
						|
                if (month.length < 2)
 | 
						|
                    month = '0' + month;
 | 
						|
                if (day.length < 2)
 | 
						|
                    day = '0' + day;
 | 
						|
 | 
						|
                return [year, month, day].join('-');
 | 
						|
            },
 | 
						|
            (value) => new SimpleDatePicker(value)),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "wikidata",
 | 
						|
            "A wikidata identifier, e.g. Q42"),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "int",
 | 
						|
            "A number",
 | 
						|
            (str) => {
 | 
						|
                str = "" + str;
 | 
						|
                return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str))
 | 
						|
            }),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "nat",
 | 
						|
            "A positive number or zero",
 | 
						|
            (str) => {
 | 
						|
                str = "" + str;
 | 
						|
                return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0
 | 
						|
            }),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "pnat",
 | 
						|
            "A strict positive number",
 | 
						|
            (str) => {
 | 
						|
                str = "" + str;
 | 
						|
                return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) > 0
 | 
						|
            }),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "direction",
 | 
						|
            "A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)",
 | 
						|
            (str) => {
 | 
						|
                str = "" + str;
 | 
						|
                return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360
 | 
						|
            },str => str,
 | 
						|
            (value) => {
 | 
						|
              return new DirectionInput(value);
 | 
						|
            }
 | 
						|
        ),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "float",
 | 
						|
            "A decimal",
 | 
						|
            (str) => !isNaN(Number(str))),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "pfloat",
 | 
						|
            "A positive decimal (incl zero)",
 | 
						|
            (str) => !isNaN(Number(str)) && Number(str) >= 0),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "email",
 | 
						|
            "An email adress",
 | 
						|
            (str) => EmailValidator.validate(str)),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "url",
 | 
						|
            "A url",
 | 
						|
            (str) => {
 | 
						|
                try {
 | 
						|
                    new URL(str);
 | 
						|
                    return true;
 | 
						|
                } catch (e) {
 | 
						|
                    return false;
 | 
						|
                }
 | 
						|
            }, (str) => {
 | 
						|
                try {
 | 
						|
                    const url = new URL(str);
 | 
						|
                    const blacklistedTrackingParams = [
 | 
						|
                        "fbclid",// Oh god, how I hate the fbclid. Let it burn, burn in hell!
 | 
						|
                        "gclid",
 | 
						|
                        "cmpid", "agid", "utm", "utm_source","utm_medium"]
 | 
						|
                    for (const dontLike of blacklistedTrackingParams) {
 | 
						|
                        url.searchParams.delete(dontLike)
 | 
						|
                    }
 | 
						|
                    return url.toString();
 | 
						|
                } catch (e) {
 | 
						|
                    console.error(e)
 | 
						|
                    return undefined;
 | 
						|
                }
 | 
						|
            }),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "phone",
 | 
						|
            "A phone number",
 | 
						|
            (str, country: () => string) => {
 | 
						|
                if (str === undefined) {
 | 
						|
                    return false;
 | 
						|
                }
 | 
						|
                return parsePhoneNumberFromString(str, (country())?.toUpperCase() as any)?.isValid() ?? false
 | 
						|
            },
 | 
						|
            (str, country: () => string) => parsePhoneNumberFromString(str, (country())?.toUpperCase() as any).formatInternational()
 | 
						|
        ),
 | 
						|
        ValidatedTextField.tp(
 | 
						|
            "opening_hours",
 | 
						|
            "Has extra elements to easily input when a POI is opened",
 | 
						|
            (s, country) => true, // TODO
 | 
						|
            str => str, 
 | 
						|
            (value) => {
 | 
						|
                return new OpeningHoursInput(value);
 | 
						|
            }
 | 
						|
        )
 | 
						|
    ]
 | 
						|
    
 | 
						|
    private static allTypesDict(){
 | 
						|
        const types = {};
 | 
						|
        for (const tp of ValidatedTextField.tpList) {
 | 
						|
            types[tp.name] = tp;
 | 
						|
        }
 | 
						|
        return types;
 | 
						|
    }
 | 
						|
 | 
						|
    public static TypeDropdown(): DropDown<string> {
 | 
						|
        const values: { value: string, shown: string }[] = [];
 | 
						|
        const expl = ValidatedTextField.tpList;
 | 
						|
        for (const key in expl) {
 | 
						|
            values.push({value: expl[key].name, shown: `${expl[key].name} - ${expl[key].explanation}`})
 | 
						|
        }
 | 
						|
        return new DropDown<string>("", values)
 | 
						|
    }
 | 
						|
 | 
						|
    /**
 | 
						|
     * {string (typename) --> TextFieldDef}
 | 
						|
     */
 | 
						|
    public static AllTypes = ValidatedTextField.allTypesDict();
 | 
						|
 | 
						|
    public static InputForType(type: string, options?: {
 | 
						|
        placeholder?: string | UIElement,
 | 
						|
        value?: UIEventSource<string>,
 | 
						|
        textArea?: boolean,
 | 
						|
        textAreaRows?: number,
 | 
						|
        isValid?: ((s: string, country: () => string) => boolean),
 | 
						|
        country?: () => string,
 | 
						|
        location?: [number /*lat*/, number /*lon*/]
 | 
						|
    }): InputElement<string> {
 | 
						|
        options = options ?? {};
 | 
						|
        options.placeholder = options.placeholder ?? type;
 | 
						|
        const tp: TextFieldDef = ValidatedTextField.AllTypes[type]
 | 
						|
        const isValidTp = tp.isValid;
 | 
						|
        let isValid;
 | 
						|
        options.textArea = options.textArea ?? type === "text";
 | 
						|
        if (options.isValid) {
 | 
						|
            const optValid = options.isValid;
 | 
						|
            isValid = (str, country) => {
 | 
						|
                if(str === undefined){
 | 
						|
                    return false;
 | 
						|
                }
 | 
						|
                return isValidTp(str, country ?? options.country) && optValid(str, country ?? options.country);
 | 
						|
            }
 | 
						|
        }else{
 | 
						|
            isValid = isValidTp;
 | 
						|
        }
 | 
						|
        options.isValid = isValid;
 | 
						|
 | 
						|
        let input: InputElement<string> = new TextField(options);
 | 
						|
        if (tp.reformat) {
 | 
						|
            input.GetValue().addCallbackAndRun(str => {
 | 
						|
                if (!options.isValid(str, options.country)) {
 | 
						|
                    return;
 | 
						|
                }
 | 
						|
                const formatted = tp.reformat(str, options.country);
 | 
						|
                input.GetValue().setData(formatted);
 | 
						|
            })
 | 
						|
        }
 | 
						|
 | 
						|
        if (tp.inputHelper) {
 | 
						|
            input = new CombinedInputElement(input, tp.inputHelper(input.GetValue(),{
 | 
						|
                location: options.location
 | 
						|
            }));
 | 
						|
        }
 | 
						|
        return input;
 | 
						|
    }
 | 
						|
 | 
						|
    public static NumberInput(type: string = "int", extraValidation: (number: Number) => boolean = undefined): InputElement<number> {
 | 
						|
        const isValid = ValidatedTextField.AllTypes[type].isValid;
 | 
						|
        extraValidation = extraValidation ?? (() => true)
 | 
						|
 | 
						|
        const fromString = str => {
 | 
						|
            if (!isValid(str)) {
 | 
						|
                return undefined;
 | 
						|
            }
 | 
						|
            const n = Number(str);
 | 
						|
            if (!extraValidation(n)) {
 | 
						|
                return undefined;
 | 
						|
            }
 | 
						|
            return n;
 | 
						|
        };
 | 
						|
        const toString = num => {
 | 
						|
            if (num === undefined) {
 | 
						|
                return undefined;
 | 
						|
            }
 | 
						|
            return "" + num;
 | 
						|
        };
 | 
						|
        const textField = ValidatedTextField.InputForType(type);
 | 
						|
        return new InputElementMap(textField, (n0, n1) => n0 === n1, fromString, toString)
 | 
						|
    }
 | 
						|
    
 | 
						|
    public static KeyInput(allowEmpty: boolean = false): InputElement<string> {
 | 
						|
 | 
						|
        function fromString(str) {
 | 
						|
            if (str?.match(/^[a-zA-Z][a-zA-Z0-9:_-]*$/)) {
 | 
						|
                return str;
 | 
						|
            }
 | 
						|
            if (str === "" && allowEmpty) {
 | 
						|
                return "";
 | 
						|
            }
 | 
						|
 | 
						|
            return undefined
 | 
						|
        }
 | 
						|
 | 
						|
        const toString = str => str
 | 
						|
 | 
						|
        function isSame(str0, str1) {
 | 
						|
            return str0 === str1;
 | 
						|
        }
 | 
						|
 | 
						|
        const textfield = new TextField({
 | 
						|
            placeholder: "key",
 | 
						|
            isValid: str => fromString(str) !== undefined,
 | 
						|
            value: new UIEventSource<string>("")
 | 
						|
        });
 | 
						|
 | 
						|
        return new InputElementMap(textfield, isSame, fromString, toString);
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
 | 
						|
    static Mapped<T>(fromString: (str) => T, toString: (T) => string, options?: {
 | 
						|
        placeholder?: string | UIElement,
 | 
						|
        type?: string,
 | 
						|
        value?: UIEventSource<string>,
 | 
						|
        startValidated?: boolean,
 | 
						|
        textArea?: boolean,
 | 
						|
        textAreaRows?: number,
 | 
						|
        isValid?: ((string: string) => boolean),
 | 
						|
        country?: () => string
 | 
						|
    }): InputElement<T> {
 | 
						|
        let textField: InputElement<string>;
 | 
						|
        if (options?.type) {
 | 
						|
            textField = ValidatedTextField.InputForType(options.type, options);
 | 
						|
        } else {
 | 
						|
            textField = new TextField(options);
 | 
						|
        }
 | 
						|
        return new InputElementMap(
 | 
						|
            textField, (a, b) => a === b,
 | 
						|
            fromString, toString
 | 
						|
        );
 | 
						|
 | 
						|
    }
 | 
						|
} |