913 lines
No EOL
29 KiB
TypeScript
913 lines
No EOL
29 KiB
TypeScript
import {DropDown} from "./DropDown";
|
|
import * as EmailValidator from "email-validator";
|
|
import {parsePhoneNumberFromString} from "libphonenumber-js";
|
|
import {InputElement} from "./InputElement";
|
|
import {TextField} from "./TextField";
|
|
import {Store, UIEventSource} from "../../Logic/UIEventSource";
|
|
import CombinedInputElement from "./CombinedInputElement";
|
|
import SimpleDatePicker from "./SimpleDatePicker";
|
|
import OpeningHoursInput from "../OpeningHours/OpeningHoursInput";
|
|
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";
|
|
import WikidataSearchBox from "../Wikipedia/WikidataSearchBox";
|
|
import Wikidata from "../../Logic/Web/Wikidata";
|
|
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
|
|
import Table from "../Base/Table";
|
|
import Combine from "../Base/Combine";
|
|
import Title from "../Base/Title";
|
|
import InputElementMap from "./InputElementMap";
|
|
import Translations from "../i18n/Translations";
|
|
import {Translation} from "../i18n/Translation";
|
|
import BaseLayer from "../../Models/BaseLayer";
|
|
import Locale from "../i18n/Locale";
|
|
|
|
export class TextFieldDef {
|
|
|
|
public readonly name: string;
|
|
/*
|
|
* An explanation for the theme builder.
|
|
* This can indicate which special input element is used, ...
|
|
* */
|
|
public readonly explanation: string;
|
|
protected inputmode?: string = undefined
|
|
|
|
constructor(name: string,
|
|
explanation: string | BaseUIElement) {
|
|
this.name = name;
|
|
if (this.name.endsWith("textfield")) {
|
|
this.name = this.name.substr(0, this.name.length - "TextField".length)
|
|
}
|
|
if (this.name.endsWith("textfielddef")) {
|
|
this.name = this.name.substr(0, this.name.length - "TextFieldDef".length)
|
|
}
|
|
if (typeof explanation === "string") {
|
|
|
|
this.explanation = explanation
|
|
} else {
|
|
this.explanation = explanation.AsMarkdown();
|
|
}
|
|
}
|
|
|
|
protectedisValid(s: string, _: (() => string) | undefined): boolean {
|
|
return true;
|
|
}
|
|
|
|
public getFeedback(s: string): Translation {
|
|
const tr = Translations.t.validation[this.name]
|
|
if(tr !== undefined){
|
|
return tr["feedback"]
|
|
}
|
|
}
|
|
|
|
public ConstructInputElement(options: {
|
|
value?: UIEventSource<string>,
|
|
inputStyle?: string,
|
|
feedback?: UIEventSource<Translation>
|
|
placeholder?: string | Translation | UIEventSource<string>,
|
|
country?: () => string,
|
|
location?: [number /*lat*/, number /*lon*/],
|
|
mapBackgroundLayer?: UIEventSource</*BaseLayer*/ any>,
|
|
unit?: Unit,
|
|
args?: (string | number | boolean | any)[] // Extra arguments for the inputHelper,
|
|
feature?: any,
|
|
} = {}): InputElement<string> {
|
|
|
|
if (options.placeholder === undefined) {
|
|
options.placeholder = Translations.t.validation[this.name]?.description ?? this.name
|
|
}
|
|
|
|
options["textArea"] = this.name === "text";
|
|
|
|
const self = this;
|
|
|
|
if (options.unit !== undefined) {
|
|
// Reformatting is handled by the unit in this case
|
|
options["isValid"] = str => {
|
|
const denom = options.unit.findDenomination(str, options?.country);
|
|
if (denom === undefined) {
|
|
return false;
|
|
}
|
|
const stripped = denom[0]
|
|
return self.isValid(stripped, options.country)
|
|
}
|
|
} else {
|
|
options["isValid"] = str => self.isValid(str, options.country);
|
|
}
|
|
options["cssText"] = "width: 100%;"
|
|
|
|
|
|
options["inputMode"] = this.inputmode;
|
|
if (this.inputmode === "text") {
|
|
options["htmlType"] = "area"
|
|
options["textAreaRows"] = 4
|
|
}
|
|
|
|
|
|
const textfield = new TextField(options);
|
|
let input: InputElement<string> = textfield
|
|
if (options.feedback) {
|
|
textfield.GetRawValue().addCallback(v => {
|
|
if(self.isValid(v, options.country)){
|
|
options.feedback.setData(undefined)
|
|
}else{
|
|
options.feedback.setData(self.getFeedback(v))
|
|
}
|
|
})
|
|
}
|
|
|
|
|
|
if (this.reformat && options.unit === undefined) {
|
|
input.GetValue().addCallbackAndRun(str => {
|
|
if (!options["isValid"](str, options.country)) {
|
|
return;
|
|
}
|
|
const formatted = this.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
|
|
|
|
|
|
const isSingular = input.GetValue().map(str => str?.trim() === "1")
|
|
|
|
const unitDropDown =
|
|
unit.denominations.length === 1 ?
|
|
new FixedInputElement(unit.denominations[0].getToggledHuman(isSingular), unit.denominations[0])
|
|
: new DropDown("",
|
|
unit.denominations.map(denom => {
|
|
return {
|
|
shown: denom.getToggledHuman(isSingular),
|
|
value: denom
|
|
}
|
|
})
|
|
)
|
|
unitDropDown.GetValue().setData(unit.getDefaultInput(options.country))
|
|
unitDropDown.SetClass("w-min")
|
|
|
|
const fixedDenom = unit.denominations.length === 1 ? unit.denominations[0] : undefined
|
|
input = new CombinedInputElement(
|
|
input,
|
|
unitDropDown,
|
|
// combine the value from the textfield and the dropdown into the resulting value that should go into OSM
|
|
(text, denom) => {
|
|
if (denom === undefined) {
|
|
return text
|
|
}
|
|
return denom?.canonicalValue(text, true)
|
|
},
|
|
(valueWithDenom: string) => {
|
|
// Take the value from OSM and feed it into the textfield and the dropdown
|
|
const withDenom = unit.findDenomination(valueWithDenom, options?.country);
|
|
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]
|
|
}
|
|
const [strippedText, denom] = withDenom
|
|
if (strippedText === undefined) {
|
|
return [undefined, fixedDenom]
|
|
}
|
|
return [strippedText, denom]
|
|
}
|
|
).SetClass("flex")
|
|
}
|
|
const helper = this.inputHelper(input.GetValue(), {
|
|
location: options.location,
|
|
mapBackgroundLayer: options.mapBackgroundLayer,
|
|
args: options.args,
|
|
feature: options.feature
|
|
})?.SetClass("block")
|
|
if (helper !== undefined) {
|
|
input = new CombinedInputElement(input, helper,
|
|
(a, _) => a, // We can ignore b, as they are linked earlier
|
|
a => [a, a]
|
|
).SetClass("block w-full");
|
|
}
|
|
if (this.postprocess !== undefined) {
|
|
input = new InputElementMap<string, string>(input,
|
|
(a, b) => a === b,
|
|
this.postprocess,
|
|
this.undoPostprocess
|
|
)
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
protected isValid(string: string, requestCountry: () => string): boolean {
|
|
return true;
|
|
}
|
|
|
|
protected reformat(s: string, country?: () => string): string {
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Modification to make before the string is uploaded to OSM
|
|
*/
|
|
protected postprocess(s: string): string {
|
|
return s
|
|
}
|
|
|
|
protected undoPostprocess(s: string): string {
|
|
return s;
|
|
}
|
|
|
|
protected inputHelper(value: UIEventSource<string>, options?: {
|
|
location: [number, number],
|
|
mapBackgroundLayer?: UIEventSource<any>,
|
|
args: (string | number | boolean | any)[]
|
|
feature?: any
|
|
}): InputElement<string> {
|
|
return undefined
|
|
}
|
|
|
|
|
|
}
|
|
|
|
class WikidataTextField extends TextFieldDef {
|
|
|
|
constructor() {
|
|
super(
|
|
"wikidata",
|
|
new Combine([
|
|
"A wikidata identifier, e.g. Q42.",
|
|
new Title("Helper arguments"),
|
|
new Table(["name", "doc"],
|
|
[
|
|
["key", "the value of this tag will initialize search (default: name)"],
|
|
["options", new Combine(["A JSON-object of type `{ removePrefixes: string[], removePostfixes: string[] }`.",
|
|
new Table(
|
|
["subarg", "doc"],
|
|
[["removePrefixes", "remove these snippets of text from the start of the passed string to search. This is either a list OR a hash of languages to a list"],
|
|
["removePostfixes", "remove these snippets of text from the end of the passed string to search. This is either a list OR a hash of languages to a list"],
|
|
["instanceOf","A list of Q-identifier which indicates that the search results _must_ be an entity of this type, e.g. [`Q5`](https://www.wikidata.org/wiki/Q5) for humans"],
|
|
["notInstanceof","A list of Q-identifiers which indicates that the search results _must not_ be an entity of this type, e.g. [`Q79007`](https://www.wikidata.org/wiki/Q79007) to filter away all streets from the search results"]
|
|
]
|
|
)])
|
|
]]),
|
|
new Title("Example usage"),
|
|
`The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name
|
|
|
|
\`\`\`json
|
|
"freeform": {
|
|
"key": "name:etymology:wikidata",
|
|
"type": "wikidata",
|
|
"helperArgs": [
|
|
"name",
|
|
{
|
|
"removePostfixes": {"en": [
|
|
"street",
|
|
"boulevard",
|
|
"path",
|
|
"square",
|
|
"plaza",
|
|
],
|
|
"nl": ["straat","plein","pad","weg",laan"]
|
|
},
|
|
|
|
"#": "Remove streets and parks from the search results:"
|
|
"notInstanceOf": ["Q79007","Q22698"]
|
|
}
|
|
|
|
]
|
|
}
|
|
\`\`\`
|
|
|
|
Another example is to search for species and trees:
|
|
|
|
\`\`\`json
|
|
"freeform": {
|
|
"key": "species:wikidata",
|
|
"type": "wikidata",
|
|
"helperArgs": [
|
|
"species",
|
|
{
|
|
"instanceOf": [10884, 16521]
|
|
}]
|
|
}
|
|
\`\`\`
|
|
`
|
|
]));
|
|
}
|
|
|
|
|
|
public isValid(str): boolean {
|
|
|
|
if (str === undefined) {
|
|
return false;
|
|
}
|
|
if (str.length <= 2) {
|
|
return false;
|
|
}
|
|
return !str.split(";").some(str => Wikidata.ExtractKey(str) === undefined)
|
|
}
|
|
|
|
public reformat(str) {
|
|
if (str === undefined) {
|
|
return undefined;
|
|
}
|
|
let out = str.split(";").map(str => Wikidata.ExtractKey(str)).join("; ")
|
|
if (str.endsWith(";")) {
|
|
out = out + ";"
|
|
}
|
|
return out;
|
|
}
|
|
|
|
public inputHelper(currentValue, inputHelperOptions) {
|
|
const args = inputHelperOptions.args ?? []
|
|
const searchKey = args[0] ?? "name"
|
|
|
|
|
|
|
|
const searchFor = <string>(inputHelperOptions.feature?.properties[searchKey]?.toLowerCase() ?? "")
|
|
|
|
let searchForValue: UIEventSource<string> = new UIEventSource(searchFor);
|
|
const options: any = args[1]
|
|
if (searchFor !== undefined && options !== undefined) {
|
|
const prefixes = <string[] | Record<string, string[]>>options["removePrefixes"] ?? []
|
|
const postfixes = <string[] | Record<string, string[]>>options["removePostfixes"] ?? []
|
|
|
|
Locale.language.map(lg => {
|
|
const prefixesUnrwapped: string[] = prefixes[lg] ?? prefixes
|
|
const postfixesUnwrapped: string[] = postfixes[lg] ?? postfixes
|
|
let clipped = searchFor;
|
|
|
|
for (const postfix of postfixesUnwrapped) {
|
|
if (searchFor.endsWith(postfix)) {
|
|
clipped = searchFor.substring(0, searchFor.length - postfix.length)
|
|
break;
|
|
}
|
|
}
|
|
|
|
for (const prefix of prefixesUnrwapped) {
|
|
if (searchFor.startsWith(prefix)) {
|
|
clipped = searchFor.substring(prefix.length)
|
|
break;
|
|
}
|
|
}
|
|
return clipped;
|
|
}).addCallbackAndRun(clipped => searchForValue.setData(clipped))
|
|
|
|
|
|
}
|
|
|
|
let instanceOf : number[] = Utils.NoNull((options?.instanceOf ?? []).map(i => Wikidata.QIdToNumber(i)))
|
|
let notInstanceOf : number[] = Utils.NoNull((options?.notInstanceOf ?? []).map(i => Wikidata.QIdToNumber(i)))
|
|
|
|
return new WikidataSearchBox({
|
|
value: currentValue,
|
|
searchText: searchForValue,
|
|
instanceOf,
|
|
notInstanceOf
|
|
})
|
|
}
|
|
}
|
|
|
|
class OpeningHoursTextField extends TextFieldDef {
|
|
|
|
constructor() {
|
|
super(
|
|
"opening_hours",
|
|
new Combine([
|
|
"Has extra elements to easily input when a POI is opened.",
|
|
new Title("Helper arguments"),
|
|
new Table(["name", "doc"],
|
|
[
|
|
["options", new Combine([
|
|
"A JSON-object of type `{ prefix: string, postfix: string }`. ",
|
|
new Table(["subarg", "doc"],
|
|
[
|
|
["prefix", "Piece of text that will always be added to the front of the generated opening hours. If the OSM-data does not start with this, it will fail to parse"],
|
|
["postfix", "Piece of text that will always be added to the end of the generated opening hours"],
|
|
])
|
|
|
|
])
|
|
]
|
|
]),
|
|
new Title("Example usage"),
|
|
"To add a conditional (based on time) access restriction:\n\n```\n" + `
|
|
"freeform": {
|
|
"key": "access:conditional",
|
|
"type": "opening_hours",
|
|
"helperArgs": [
|
|
{
|
|
"prefix":"no @ (",
|
|
"postfix":")"
|
|
}
|
|
]
|
|
}` + "\n```\n\n*Don't forget to pass the prefix and postfix in the rendering as well*: `{opening_hours_table(opening_hours,yes @ &LPARENS, &RPARENS )`"]),);
|
|
}
|
|
|
|
isValid() {
|
|
return true
|
|
}
|
|
|
|
reformat(str) {
|
|
return str
|
|
}
|
|
|
|
inputHelper(value: UIEventSource<string>, inputHelperOptions: {
|
|
location: [number, number],
|
|
mapBackgroundLayer?: UIEventSource<any>,
|
|
args: (string | number | boolean | any)[]
|
|
feature?: any
|
|
}) {
|
|
const args = (inputHelperOptions.args ?? [])[0]
|
|
const prefix = <string>args?.prefix ?? ""
|
|
const postfix = <string>args?.postfix ?? ""
|
|
|
|
return new OpeningHoursInput(value, prefix, postfix)
|
|
}
|
|
}
|
|
|
|
class UrlTextfieldDef extends TextFieldDef {
|
|
|
|
inputmode: "url"
|
|
|
|
constructor() {
|
|
super("url", "The validatedTextField will format URLs to always be valid and have a https://-header (even though the 'https'-part will be hidden from the user")
|
|
}
|
|
|
|
postprocess(str: string) {
|
|
if (str === undefined) {
|
|
return undefined
|
|
}
|
|
if (!str.startsWith("http://") || !str.startsWith("https://")) {
|
|
return "https://" + str
|
|
}
|
|
return str;
|
|
}
|
|
|
|
undoPostprocess(str: string) {
|
|
if (str === undefined) {
|
|
return undefined
|
|
}
|
|
if (str.startsWith("http://")) {
|
|
return str.substr("http://".length)
|
|
}
|
|
if (str.startsWith("https://")) {
|
|
return str.substr("https://".length)
|
|
}
|
|
return str;
|
|
}
|
|
|
|
reformat(str: string): string {
|
|
try {
|
|
let url: URL
|
|
// str = str.toLowerCase() // URLS are case sensitive. Lowercasing them might break some URLS. See #763
|
|
if (!str.startsWith("http://") && !str.startsWith("https://") && !str.startsWith("http:")) {
|
|
url = new URL("https://" + str)
|
|
} else {
|
|
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",
|
|
"campaignid", "campaign", "AdGroupId", "AdGroup", "TargetId", "msclkid"]
|
|
for (const dontLike of blacklistedTrackingParams) {
|
|
url.searchParams.delete(dontLike.toLowerCase())
|
|
}
|
|
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)
|
|
}
|
|
|
|
if (cleaned.startsWith("https://")) {
|
|
cleaned = cleaned.substr("https://".length)
|
|
}
|
|
|
|
return cleaned;
|
|
} catch (e) {
|
|
console.error(e)
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
isValid(str: string): boolean {
|
|
try {
|
|
if (!str.startsWith("http://") && !str.startsWith("https://") &&
|
|
!str.startsWith("http:")) {
|
|
str = "https://" + str
|
|
}
|
|
const url = new URL(str);
|
|
const dotIndex = url.host.indexOf(".")
|
|
return dotIndex > 0 && url.host[url.host.length - 1] !== ".";
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
class StringTextField extends TextFieldDef {
|
|
constructor() {
|
|
super("string", "A simple piece of text");
|
|
}
|
|
}
|
|
|
|
class TextTextField extends TextFieldDef {
|
|
inputmode: "text"
|
|
|
|
constructor() {
|
|
super("text", "A longer piece of text");
|
|
}
|
|
}
|
|
|
|
class DateTextField extends TextFieldDef {
|
|
constructor() {
|
|
super("date", "A date with date picker");
|
|
}
|
|
|
|
isValid = (str) => {
|
|
return !isNaN(new Date(str).getTime());
|
|
}
|
|
|
|
reformat(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('-');
|
|
}
|
|
|
|
inputHelper(value) {
|
|
return new SimpleDatePicker(value)
|
|
}
|
|
}
|
|
|
|
|
|
class LengthTextField extends TextFieldDef {
|
|
inputMode: "decimal"
|
|
|
|
constructor() {
|
|
super(
|
|
"distance", "A geographical distance 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\"]"
|
|
)
|
|
}
|
|
|
|
isValid = (str) => {
|
|
const t = Number(str)
|
|
return !isNaN(t)
|
|
}
|
|
|
|
inputHelper = (value: UIEventSource<string>, options:
|
|
{ location?: [number,number]; args?: string[]; feature?: any; mapBackgroundLayer?: Store<BaseLayer>; }) => {
|
|
options = options ?? {}
|
|
options.location = options.location ?? [0, 0]
|
|
|
|
const args = options.args ?? []
|
|
let zoom = 19
|
|
if (args[0]) {
|
|
zoom = Number(args[0])
|
|
if (isNaN(zoom)) {
|
|
console.error("Invalid zoom level for argument at 'length'-input. The offending argument is: ", args[0], " (using 19 instead)")
|
|
zoom = 19
|
|
}
|
|
}
|
|
|
|
// 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(/*Changes a clone, this is safe */)
|
|
options.location = <[number, number]>GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates
|
|
options.location.reverse(/*Changes a clone, this is safe */)
|
|
}
|
|
|
|
|
|
const location = new UIEventSource<Loc>({
|
|
lat: options.location[0],
|
|
lon: options.location[1],
|
|
zoom: zoom
|
|
})
|
|
if (args[1]) {
|
|
// We have a prefered map!
|
|
options.mapBackgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(
|
|
location, new UIEventSource<string[]>(args[1].split(","))
|
|
)
|
|
}
|
|
const background = options?.mapBackgroundLayer
|
|
const li = new LengthInput(new UIEventSource<BaseLayer>(background.data), location, value)
|
|
li.SetStyle("height: 20rem;")
|
|
return li;
|
|
}
|
|
}
|
|
|
|
class FloatTextField extends TextFieldDef {
|
|
inputmode = "decimal"
|
|
|
|
constructor(name?: string, explanation?: string) {
|
|
super(name ?? "float", explanation ?? "A decimal");
|
|
}
|
|
|
|
isValid(str) {
|
|
return !isNaN(Number(str)) && !str.endsWith(".") && !str.endsWith(",")
|
|
}
|
|
|
|
reformat( str): string {
|
|
return "" + Number(str);
|
|
}
|
|
|
|
getFeedback(s: string): Translation {
|
|
if (isNaN(Number(s))) {
|
|
return Translations.t.validation.nat.notANumber
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
class IntTextField extends FloatTextField {
|
|
inputMode = "numeric"
|
|
|
|
constructor(name?: string, explanation?: string) {
|
|
super(name ?? "int", explanation ?? "A number");
|
|
}
|
|
|
|
isValid(str): boolean {
|
|
str = "" + str;
|
|
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str))
|
|
}
|
|
|
|
getFeedback(s: string): Translation {
|
|
const n = Number(s)
|
|
if (isNaN(n)) {
|
|
return Translations.t.validation.nat.notANumber
|
|
}
|
|
if (Math.floor(n) !== n) {
|
|
return Translations.t.validation.nat.mustBeWhole
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
}
|
|
|
|
class NatTextField extends IntTextField {
|
|
inputMode = "numeric"
|
|
|
|
constructor(name?: string, explanation?: string) {
|
|
super(name ?? "nat", explanation ?? "A positive number or zero");
|
|
}
|
|
|
|
isValid(str): boolean {
|
|
if (str === undefined) {
|
|
return false;
|
|
}
|
|
str = "" + str;
|
|
|
|
return str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0
|
|
}
|
|
|
|
|
|
getFeedback(s: string): Translation {
|
|
const spr = super.getFeedback(s)
|
|
if (spr !== undefined) {
|
|
return spr
|
|
}
|
|
const n = Number(s)
|
|
if (n < 0) {
|
|
return Translations.t.validation.nat.mustBePositive
|
|
}
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
class PNatTextField extends NatTextField {
|
|
inputmode = "numeric"
|
|
|
|
constructor() {
|
|
super("pnat", "A strict positive number");
|
|
}
|
|
|
|
getFeedback(s: string): Translation {
|
|
const spr = super.getFeedback(s);
|
|
if (spr !== undefined) {
|
|
return spr
|
|
}
|
|
if (Number(s) === 0) {
|
|
return Translations.t.validation.pnat.noZero
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
isValid = (str) => {
|
|
if (!super.isValid(str)) {
|
|
return false
|
|
}
|
|
return Number(str) > 0
|
|
}
|
|
|
|
}
|
|
|
|
class PFloatTextField extends FloatTextField {
|
|
inputmode = "decimal"
|
|
|
|
constructor() {
|
|
super("pfloat", "A positive decimal (inclusive zero)");
|
|
}
|
|
|
|
isValid = (str) => !isNaN(Number(str)) && Number(str) >= 0 && !str.endsWith(".") && !str.endsWith(",")
|
|
|
|
getFeedback(s: string): Translation {
|
|
const spr = super.getFeedback(s);
|
|
if (spr !== undefined) {
|
|
return spr
|
|
}
|
|
if (Number(s) < 0) {
|
|
return Translations.t.validation.nat.mustBePositive
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
class EmailTextField extends TextFieldDef {
|
|
inputmode = "email"
|
|
|
|
constructor() {
|
|
super("email", "An email adress");
|
|
}
|
|
|
|
isValid = (str) => {
|
|
if (str === undefined) {
|
|
return false
|
|
}
|
|
str = str.trim()
|
|
if (str.startsWith("mailto:")) {
|
|
str = str.substring("mailto:".length)
|
|
}
|
|
return EmailValidator.validate(str);
|
|
}
|
|
|
|
reformat = str => {
|
|
if (str === undefined) {
|
|
return undefined
|
|
}
|
|
str = str.trim()
|
|
if (str.startsWith("mailto:")) {
|
|
str = str.substring("mailto:".length)
|
|
}
|
|
return str;
|
|
}
|
|
|
|
getFeedback(s: string): Translation {
|
|
if(s.indexOf('@') < 0){return Translations.t.validation.email.noAt}
|
|
|
|
return super.getFeedback(s);
|
|
}
|
|
}
|
|
|
|
class PhoneTextField extends TextFieldDef {
|
|
inputmode = "tel"
|
|
|
|
constructor() {
|
|
super("phone", "A phone number");
|
|
}
|
|
|
|
isValid(str, country: () => string): boolean {
|
|
if (str === undefined) {
|
|
return false;
|
|
}
|
|
if (str.startsWith("tel:")) {
|
|
str = str.substring("tel:".length)
|
|
}
|
|
let countryCode = undefined
|
|
if(country !== undefined){
|
|
countryCode = (country())?.toUpperCase()
|
|
}
|
|
return parsePhoneNumberFromString(str, countryCode)?.isValid() ?? false
|
|
}
|
|
|
|
reformat = (str, country: () => string) => {
|
|
if (str.startsWith("tel:")) {
|
|
str = str.substring("tel:".length)
|
|
}
|
|
return parsePhoneNumberFromString(str, (country())?.toUpperCase() as any)?.formatInternational();
|
|
}
|
|
}
|
|
|
|
class ColorTextField extends TextFieldDef {
|
|
constructor() {
|
|
super("color", "Shows a color picker");
|
|
}
|
|
|
|
inputHelper = (value) => {
|
|
return new ColorPicker(value.map(color => {
|
|
return Utils.ColourNameToHex(color ?? "");
|
|
}, [], str => Utils.HexToColourName(str)))
|
|
}
|
|
}
|
|
|
|
class DirectionTextField extends IntTextField {
|
|
inputMode = "numeric"
|
|
|
|
constructor() {
|
|
super("direction", "A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)");
|
|
}
|
|
|
|
reformat(str): string {
|
|
const n = (Number(str) % 360)
|
|
return ""+n
|
|
}
|
|
|
|
|
|
inputHelper = (value, options) => {
|
|
const args = options.args ?? []
|
|
options.location = options.location ?? [0, 0]
|
|
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!
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
export default class ValidatedTextField {
|
|
|
|
private static AllTextfieldDefs: TextFieldDef[] = [
|
|
new StringTextField(),
|
|
new TextTextField(),
|
|
new DateTextField(),
|
|
new NatTextField(),
|
|
new IntTextField(),
|
|
new LengthTextField(),
|
|
new DirectionTextField(),
|
|
new WikidataTextField(),
|
|
new PNatTextField(),
|
|
new FloatTextField(),
|
|
new PFloatTextField(),
|
|
new EmailTextField(),
|
|
new UrlTextfieldDef(),
|
|
new PhoneTextField(),
|
|
new OpeningHoursTextField(),
|
|
new ColorTextField()
|
|
]
|
|
public static allTypes: Map<string, TextFieldDef> = ValidatedTextField.allTypesDict();
|
|
public static ForType(type: string = "string"): TextFieldDef {
|
|
const def = ValidatedTextField.allTypes.get(type)
|
|
if(def === undefined){
|
|
console.warn("Something tried to load a validated text field named",type, "but this type does not exist")
|
|
return this.ForType()
|
|
}
|
|
return def
|
|
}
|
|
|
|
public static HelpText(): BaseUIElement {
|
|
const explanations: BaseUIElement[] =
|
|
ValidatedTextField.AllTextfieldDefs.map(type =>
|
|
new Combine([new Title(type.name, 3), type.explanation]).SetClass("flex flex-col"))
|
|
return new Combine([
|
|
new Title("Available types for text fields", 1),
|
|
"The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them",
|
|
...explanations
|
|
]).SetClass("flex flex-col")
|
|
}
|
|
|
|
public static AvailableTypes(): string[] {
|
|
return ValidatedTextField.AllTextfieldDefs.map(tp => tp.name)
|
|
}
|
|
|
|
private static allTypesDict(): Map<string, TextFieldDef> {
|
|
const types = new Map<string, TextFieldDef>();
|
|
for (const tp of ValidatedTextField.AllTextfieldDefs) {
|
|
types[tp.name] = tp;
|
|
types.set(tp.name, tp);
|
|
}
|
|
return types;
|
|
}
|
|
} |