mapcomplete/UI/Input/ValidatedTextField.ts

503 lines
19 KiB
TypeScript
Raw Normal View History

2020-09-25 12:44:04 +02:00
import {DropDown} from "./DropDown";
import * as EmailValidator from "email-validator";
import {parsePhoneNumberFromString} from "libphonenumber-js";
import {InputElement} from "./InputElement";
import {TextField} from "./TextField";
import {UIEventSource} from "../../Logic/UIEventSource";
import CombinedInputElement from "./CombinedInputElement";
import SimpleDatePicker from "./SimpleDatePicker";
import OpeningHoursInput from "../OpeningHours/OpeningHoursInput";
2020-11-15 03:10:44 +01:00
import DirectionInput from "./DirectionInput";
import ColorPicker from "./ColorPicker";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
import BaseUIElement from "../BaseUIElement";
import LengthInput from "./LengthInput";
import {GeoOperations} from "../../Logic/GeoOperations";
import {Unit} from "../../Models/Unit";
import {FixedInputElement} from "./FixedInputElement";
2021-10-08 04:33:39 +02:00
import WikidataSearchBox from "../Wikipedia/WikidataSearchBox";
import Wikidata from "../../Logic/Web/Wikidata";
2021-10-15 14:52:11 +02:00
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
interface TextFieldDef {
name: string,
explanation: string,
isValid: ((s: string, country?: () => string) => boolean),
2020-12-05 03:22:17 +01:00
reformat?: ((s: string, country?: () => string) => string),
2020-11-17 02:22:48 +01:00
inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number],
mapBackgroundLayer?: UIEventSource<any>,
args: (string | number | boolean)[]
feature?: any
2020-11-17 02:22:48 +01:00
}) => InputElement<string>,
inputmode?: string
}
2020-09-25 12:44:04 +02:00
export default class ValidatedTextField {
public static tpList: TextFieldDef[] = [
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"string",
"A basic string"),
2020-10-27 01:01:34 +01:00
ValidatedTextField.tp(
"text",
"A string, but allows input of longer strings more comfortably (a text area)",
undefined,
undefined,
undefined,
"text"),
2021-06-16 17:09:32 +02:00
2020-09-25 12:44:04 +02:00
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(
"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, options) => {
const args = options.args ?? []
let zoom = 19
if (args[0]) {
zoom = Number(args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
}
}
const location = new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: zoom
})
if (args[1]) {
// We have a prefered map!
2021-10-15 14:52:11 +02:00
options.mapBackgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(
location, new UIEventSource<string[]>(args[1].split(","))
)
}
const di = new DirectionInput(options.mapBackgroundLayer, location, value)
di.SetStyle("max-width: 25rem;");
return di;
},
"numeric"
),
ValidatedTextField.tp(
"length",
"A geographical length in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma separated) ], e.g. `[\"21\", \"map,photo\"]",
(str) => {
const t = Number(str)
return !isNaN(t)
},
str => str,
(value, options) => {
const args = options.args ?? []
let zoom = 19
if (args[0]) {
zoom = Number(args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
}
}
// Bit of a hack: we project the centerpoint to the closes point on the road - if available
if (options.feature !== undefined && options.feature.geometry.type !== "Point") {
const lonlat: [number, number] = [...options.location]
lonlat.reverse()
options.location = <[number, number]>GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates
options.location.reverse()
}
const location = new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: zoom
})
if (args[1]) {
// We have a prefered map!
2021-10-15 14:52:11 +02:00
options.mapBackgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(
location, new UIEventSource<string[]>(args[1].split(","))
)
}
const li = new LengthInput(options.mapBackgroundLayer, location, value)
li.SetStyle("height: 20rem;")
return li;
},
"decimal"
),
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"wikidata",
2021-10-08 04:33:39 +02:00
"A wikidata identifier, e.g. Q42. Input helper arguments: [ key: the value of this tag will initialize search (default: name), options: { removePrefixes: string[], removePostfixes: string[] } these prefixes and postfixes will be removed from the initial search value]",
2021-06-16 17:09:32 +02:00
(str) => {
if (str === undefined) {
return false;
}
if(str.length <= 2){
return false;
}
return !str.split(";").some(str => Wikidata.ExtractKey(str) === undefined)
2021-06-16 17:09:32 +02:00
},
(str) => {
if (str === undefined) {
return undefined;
}
let out = str.split(";").map(str => Wikidata.ExtractKey(str)).join("; ")
if(str.endsWith(";")){
out = out + ";"
2021-06-16 17:09:32 +02:00
}
return out;
2021-10-08 04:33:39 +02:00
},
(currentValue, inputHelperOptions) => {
const args = inputHelperOptions.args ?? []
2021-10-08 04:33:39 +02:00
const searchKey = args[0] ?? "name"
let searchFor = <string>inputHelperOptions.feature?.properties[searchKey]?.toLowerCase()
const options = args[1]
if (searchFor !== undefined && options !== undefined) {
const prefixes = <string[]>options["removePrefixes"]
const postfixes = <string[]>options["removePostfixes"]
for (const postfix of postfixes ?? []) {
if (searchFor.endsWith(postfix)) {
searchFor = searchFor.substring(0, searchFor.length - postfix.length)
break;
}
}
for (const prefix of prefixes ?? []) {
if (searchFor.startsWith(prefix)) {
searchFor = searchFor.substring(prefix.length)
break;
}
}
}
return new WikidataSearchBox({
value: currentValue,
searchText: new UIEventSource<string>(searchFor)
})
}
),
2021-06-16 17:09:32 +02:00
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"int",
"A number",
(str) => {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str))
},
2021-09-06 01:40:10 +02:00
str => "" + Number(str),
undefined,
"numeric"),
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"nat",
"A positive number or zero",
(str) => {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0
},
2021-09-06 01:40:10 +02:00
str => "" + Number(str),
undefined,
"numeric"),
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"pnat",
"A strict positive number",
(str) => {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) > 0
},
2021-09-06 01:40:10 +02:00
str => "" + Number(str),
undefined,
"numeric"),
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"float",
"A decimal",
(str) => !isNaN(Number(str)) && !str.endsWith(".") && !str.endsWith(","),
2021-09-06 01:40:10 +02:00
str => "" + Number(str),
undefined,
"decimal"),
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"pfloat",
"A positive decimal (incl zero)",
(str) => !isNaN(Number(str)) && Number(str) >= 0 && !str.endsWith(".") && !str.endsWith(","),
2021-09-06 01:40:10 +02:00
str => "" + Number(str),
undefined,
"decimal"),
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"email",
"An email adress",
(str) => EmailValidator.validate(str),
undefined,
undefined,
"email"),
2020-09-25 12:44:04 +02:00
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)
}
let cleaned = url.toString();
if (cleaned.endsWith("/") && !str.endsWith("/")) {
// Do not add a trailing '/' if it wasn't typed originally
cleaned = cleaned.substr(0, cleaned.length - 1)
}
return cleaned;
} catch (e) {
console.error(e)
return undefined;
}
},
undefined,
"url"),
2020-09-25 12:44:04 +02:00
ValidatedTextField.tp(
"phone",
"A phone number",
2020-12-05 03:22:17 +01:00
(str, country: () => string) => {
if (str === undefined) {
return false;
}
2020-12-05 03:22:17 +01:00
return parsePhoneNumberFromString(str, (country())?.toUpperCase() as any)?.isValid() ?? false
2020-09-25 12:44:04 +02:00
},
(str, country: () => string) => parsePhoneNumberFromString(str, (country())?.toUpperCase() as any).formatInternational(),
undefined,
"tel"
2020-10-04 12:55:44 +02:00
),
ValidatedTextField.tp(
"opening_hours",
"Has extra elements to easily input when a POI is opened",
() => true,
str => str,
2020-10-04 12:55:44 +02:00
(value) => {
2020-10-08 19:03:00 +02:00
return new OpeningHoursInput(value);
2020-10-04 12:55:44 +02:00
}
),
ValidatedTextField.tp(
"color",
"Shows a color picker",
() => true,
str => str,
(value) => {
return new ColorPicker(value.map(color => {
return Utils.ColourNameToHex(color ?? "");
}, [], str => Utils.HexToColourName(str)))
}
2020-09-25 12:44:04 +02:00
)
]
/**
* {string (typename) --> TextFieldDef}
*/
public static AllTypes = ValidatedTextField.allTypesDict();
public static InputForType(type: string, options?: {
placeholder?: string | BaseUIElement,
value?: UIEventSource<string>,
htmlType?: string,
2021-06-16 17:09:32 +02:00
textArea?: boolean,
inputMode?: string,
textAreaRows?: number,
2020-12-05 03:22:17 +01:00
isValid?: ((s: string, country: () => string) => boolean),
country?: () => string,
location?: [number /*lat*/, number /*lon*/],
mapBackgroundLayer?: UIEventSource<any>,
unit?: Unit,
args?: (string | number | boolean)[] // Extra arguments for the inputHelper,
feature?: any
}): InputElement<string> {
options = options ?? {};
options.placeholder = options.placeholder ?? type;
const tp: TextFieldDef = ValidatedTextField.AllTypes[type]
2020-09-26 21:00:03 +02:00
const isValidTp = tp.isValid;
let isValid;
2020-10-27 01:01:34 +01:00
options.textArea = options.textArea ?? type === "text";
if (options.isValid) {
const optValid = options.isValid;
isValid = (str, country) => {
if (str === undefined) {
2020-09-26 21:00:03 +02:00
return false;
}
if (options.unit) {
str = options.unit.stripUnitParts(str)
}
2020-09-26 21:00:03 +02:00
return isValidTp(str, country ?? options.country) && optValid(str, country ?? options.country);
}
} else {
2020-09-26 21:00:03 +02:00
isValid = isValidTp;
}
if (options.unit !== undefined && isValid !== undefined) {
// Reformatting is handled by the unit in this case
options.isValid = str => {
const denom = options.unit.findDenomination(str);
if (denom === undefined) {
return false;
}
const stripped = denom[0]
console.log("Is valid? ", str, "stripped: ", stripped, "isValid:", isValid(stripped))
return isValid(stripped)
}
} else {
options.isValid = isValid;
}
options.inputMode = tp.inputmode;
let input: InputElement<string> = new TextField(options);
if (tp.reformat && options.unit === undefined) {
2020-09-26 21:00:03 +02:00
input.GetValue().addCallbackAndRun(str => {
if (!options.isValid(str, options.country)) {
return;
}
const formatted = tp.reformat(str, options.country);
input.GetValue().setData(formatted);
})
}
if (options.unit) {
// We need to apply a unit.
// This implies:
// We have to create a dropdown with applicable denominations, and fuse those values
const unit = options.unit
2021-10-08 04:33:39 +02:00
2021-09-13 02:38:20 +02:00
const isSingular = input.GetValue().map(str => str?.trim() === "1")
const unitDropDown =
unit.denominations.length === 1 ?
2021-10-08 04:33:39 +02:00
new FixedInputElement(unit.denominations[0].getToggledHuman(isSingular), unit.denominations[0])
: new DropDown("",
unit.denominations.map(denom => {
return {
2021-09-13 02:38:20 +02:00
shown: denom.getToggledHuman(isSingular),
value: denom
}
})
)
unitDropDown.GetValue().setData(unit.defaultDenom)
unitDropDown.SetClass("w-min")
2021-10-08 04:33:39 +02:00
const fixedDenom = unit.denominations.length === 1 ? unit.denominations[0] : undefined
input = new CombinedInputElement(
input,
unitDropDown,
2021-07-04 20:36:19 +02:00
// combine the value from the textfield and the dropdown into the resulting value that should go into OSM
(text, denom) => {
2021-10-08 04:33:39 +02:00
if (denom === undefined) {
return text
}
2021-10-08 04:33:39 +02:00
return denom?.canonicalValue(text, true)
},
(valueWithDenom: string) => {
2021-07-04 20:36:19 +02:00
// Take the value from OSM and feed it into the textfield and the dropdown
const withDenom = unit.findDenomination(valueWithDenom);
if (withDenom === undefined) {
// Not a valid value at all - we give it undefined and leave the details up to the other elements (but we keep the previous denomination)
return [undefined, fixedDenom]
}
2021-07-04 20:36:19 +02:00
const [strippedText, denom] = withDenom
if (strippedText === undefined) {
return [undefined, fixedDenom]
2021-07-04 20:36:19 +02:00
}
return [strippedText, denom]
}
).SetClass("flex")
}
if (tp.inputHelper) {
const helper = tp.inputHelper(input.GetValue(), {
location: options.location,
mapBackgroundLayer: options.mapBackgroundLayer,
args: options.args,
feature: options.feature
2021-09-13 02:45:51 +02:00
}).SetClass("block")
input = new CombinedInputElement(input, helper,
2021-06-24 01:55:45 +02:00
(a, _) => a, // We can ignore b, as they are linked earlier
a => [a, a]
2021-09-13 02:45:51 +02:00
).SetClass("block w-full");
}
return input;
2020-09-25 12:44:04 +02:00
}
public static HelpText(): string {
const explanations = ValidatedTextField.tpList.map(type => ["## " + type.name, "", type.explanation].join("\n")).join("\n\n")
return "# Available types for text fields\n\nThe listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them\n\n" + explanations
}
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],
mapBackgroundLayer: UIEventSource<any>,
args: string[],
feature: any
}) => InputElement<string>,
inputmode?: string): TextFieldDef {
if (isValid === undefined) {
isValid = () => true;
}
if (reformat === undefined) {
reformat = (str, _) => str;
}
return {
name: name,
explanation: explanation,
isValid: isValid,
reformat: reformat,
inputHelper: inputHelper,
inputmode: inputmode
}
}
private static allTypesDict() {
const types = {};
for (const tp of ValidatedTextField.tpList) {
types[tp.name] = tp;
}
return types;
}
2020-09-25 12:44:04 +02:00
}