diff --git a/Models/ThemeConfig/FilterConfig.ts b/Models/ThemeConfig/FilterConfig.ts index cc7c0918c0..acba35184f 100644 --- a/Models/ThemeConfig/FilterConfig.ts +++ b/Models/ThemeConfig/FilterConfig.ts @@ -56,8 +56,8 @@ export default class FilterConfig { const fields: { name: string, type: string }[] = ((option.fields) ?? []).map((f, i) => { const type = f.type ?? "string" - if (!ValidatedTextField.AllTypes.has(type)) { - throw `Invalid filter: ${type} is not a valid validated textfield type (at ${ctx}.fields[${i}])\n\tTry one of ${Array.from(ValidatedTextField.AllTypes.keys()).join(",")}` + if (!ValidatedTextField.ForType(type) === undefined) { + throw `Invalid filter: ${type} is not a valid validated textfield type (at ${ctx}.fields[${i}])\n\tTry one of ${Array.from(ValidatedTextField.AvailableTypes()).join(",")}` } if (f.name === undefined || f.name === "" || f.name.match(/[a-z0-9_-]+/) == null) { throw `Invalid filter: a variable name should match [a-z0-9_-]+ at ${ctx}.fields[${i}]` diff --git a/Models/ThemeConfig/TagRenderingConfig.ts b/Models/ThemeConfig/TagRenderingConfig.ts index a8c8476e02..11d17dee9e 100644 --- a/Models/ThemeConfig/TagRenderingConfig.ts +++ b/Models/ThemeConfig/TagRenderingConfig.ts @@ -133,7 +133,7 @@ export default class TagRenderingConfig { } - if (!ValidatedTextField.AllTypes.has(this.freeform.type)) { + if (!ValidatedTextField.ForType(this.freeform.key) === undefined) { const knownKeys = ValidatedTextField.AvailableTypes().join(", "); throw `Freeform.key ${this.freeform.key} is an invalid type. Known keys are ${knownKeys}` } diff --git a/UI/AutomatonGui.ts b/UI/AutomatonGui.ts index db5d6a6455..4e9e0faeb4 100644 --- a/UI/AutomatonGui.ts +++ b/UI/AutomatonGui.ts @@ -254,7 +254,7 @@ class AutomatonGui { LocalStorageSource.Get("automation-theme-id", "missing_streets").syncWith(themeSelect.GetValue()) - const tilepath = ValidatedTextField.InputForType("url", { + const tilepath = ValidatedTextField.ForType("url").ConstructInputElement({ placeholder: "Specifiy the path of the overview", inputStyle: "width: 100%" }) @@ -305,7 +305,7 @@ class AutomatonGui { return Array.from(rezoomed) }) - const extraComment = ValidatedTextField.InputForType("text") + const extraComment = ValidatedTextField.ForType("text").ConstructInputElement() LocalStorageSource.Get("automaton-extra-comment").syncWith(extraComment.GetValue()) return new Combine([ diff --git a/UI/BigComponents/FilterView.ts b/UI/BigComponents/FilterView.ts index 53a8b33e9d..9c83a98895 100644 --- a/UI/BigComponents/FilterView.ts +++ b/UI/BigComponents/FilterView.ts @@ -181,7 +181,7 @@ export default class FilterView extends VariableUiElement { const properties = new UIEventSource({}) for (const {name, type} of filter.fields) { const value = QueryParameters.GetQueryParameter("filter-" + filterConfig.id + "-" + name, "", "Value for filter " + filterConfig.id) - const field = ValidatedTextField.InputForType(type, { + const field = ValidatedTextField.ForType(type).ConstructInputElement({ value }).SetClass("inline-block") mappings.set(name, field) diff --git a/UI/ImportFlow/AskMetadata.ts b/UI/ImportFlow/AskMetadata.ts index 5f7066f262..46562be4c2 100644 --- a/UI/ImportFlow/AskMetadata.ts +++ b/UI/ImportFlow/AskMetadata.ts @@ -29,17 +29,17 @@ export class AskMetadata extends Combine implements FlowStep<{ constructor(params: ({ features: any[], layer: LayerConfig })) { - const introduction = ValidatedTextField.InputForType("text", { + const introduction = ValidatedTextField.ForType("text").ConstructInputElement({ value: LocalStorageSource.Get("import-helper-introduction-text"), inputStyle: "width: 100%" }) - const wikilink = ValidatedTextField.InputForType("string", { + const wikilink = ValidatedTextField.ForType("string").ConstructInputElement({ value: LocalStorageSource.Get("import-helper-wikilink-text"), inputStyle: "width: 100%" }) - const source = ValidatedTextField.InputForType("string", { + const source = ValidatedTextField.ForType("string").ConstructInputElement({ value: LocalStorageSource.Get("import-helper-source-text"), inputStyle: "width: 100%" }) @@ -59,7 +59,7 @@ export class AskMetadata extends Combine implements FlowStep<{ const theme = new DropDown("Which theme should be linked in the note?", options) - ValidatedTextField.InputForType("string", { + ValidatedTextField.ForType("string").ConstructInputElement({ value: LocalStorageSource.Get("import-helper-theme-text"), inputStyle: "width: 100%" }) diff --git a/UI/ImportFlow/ConflationChecker.ts b/UI/ImportFlow/ConflationChecker.ts index 7d56d1c401..b197d42684 100644 --- a/UI/ImportFlow/ConflationChecker.ts +++ b/UI/ImportFlow/ConflationChecker.ts @@ -92,7 +92,7 @@ export default class ConflationChecker extends Combine implements FlowStep<{ fea const background = new UIEventSource(AvailableBaseLayers.osmCarto) const location = new UIEventSource({lat: 0, lon: 0, zoom: 1}) const currentBounds = new UIEventSource(undefined) - const zoomLevel = ValidatedTextField.InputForType("pnat") + const zoomLevel = ValidatedTextField.ForType("pnat").ConstructInputElement() zoomLevel.SetClass("ml-1 border border-black") zoomLevel.GetValue().syncWith(LocalStorageSource.Get("importer-zoom-level", "14"), true) const osmLiveData = Minimap.createMiniMap({ @@ -146,7 +146,7 @@ export default class ConflationChecker extends Combine implements FlowStep<{ fea features: new StaticFeatureSource(toImport.features, false) }) - const nearbyCutoff = ValidatedTextField.InputForType("pnat") + const nearbyCutoff = ValidatedTextField.ForType("pnat").ConstructInputElement() nearbyCutoff.SetClass("ml-1 border border-black") nearbyCutoff.GetValue().syncWith(LocalStorageSource.Get("importer-cutoff", "25"), true) diff --git a/UI/ImportFlow/ImportViewerGui.ts b/UI/ImportFlow/ImportViewerGui.ts index f199c76220..7da6603d66 100644 --- a/UI/ImportFlow/ImportViewerGui.ts +++ b/UI/ImportFlow/ImportViewerGui.ts @@ -47,7 +47,7 @@ interface NoteState { class MassAction extends Combine { constructor(state: UserRelatedState, props: NoteProperties[]) { - const textField = ValidatedTextField.InputForType("text") + const textField = ValidatedTextField.ForType("text").ConstructInputElement() const actions = new DropDown<{ predicate: (p: NoteProperties) => boolean, diff --git a/UI/Input/TextField.ts b/UI/Input/TextField.ts index d4106c321a..905188a159 100644 --- a/UI/Input/TextField.ts +++ b/UI/Input/TextField.ts @@ -8,7 +8,8 @@ export class TextField extends InputElement { private readonly value: UIEventSource; private _element: HTMLElement; private readonly _isValid: (s: string, country?: () => string) => boolean; - + private _rawValue: UIEventSource + constructor(options?: { placeholder?: string | BaseUIElement, value?: UIEventSource, @@ -23,6 +24,7 @@ export class TextField extends InputElement { const self = this; options = options ?? {}; this.value = options?.value ?? new UIEventSource(undefined); + this._rawValue = new UIEventSource("") this._isValid = options.isValid ?? (_ => true); const placeholder = Translations.W(options.placeholder ?? "").ConstructElement().innerText.replace("'", "'"); @@ -77,6 +79,7 @@ export class TextField extends InputElement { const endDistance = field.value.substring(field.selectionEnd).replace(/ /g, '').length; // @ts-ignore let val: string = field.value; + self._rawValue.setData(val) if (!self.IsValid(val)) { self.value.setData(undefined); } else { @@ -128,7 +131,11 @@ export class TextField extends InputElement { GetValue(): UIEventSource { return this.value; } - + + GetRawValue(): UIEventSource{ + return this._rawValue + } + IsValid(t: string): boolean { if (t === undefined || t === null) { return false diff --git a/UI/Input/ValidatedTextField.ts b/UI/Input/ValidatedTextField.ts index 36885dcbed..75b561319d 100644 --- a/UI/Input/ValidatedTextField.ts +++ b/UI/Input/ValidatedTextField.ts @@ -25,8 +25,9 @@ import Title from "../Base/Title"; import InputElementMap from "./InputElementMap"; import Translations from "../i18n/Translations"; import {Translation} from "../i18n/Translation"; +import {NOTFOUND} from "dns"; -class SimpleTextFieldDef { +export class TextFieldDef { public readonly name: string; /* @@ -34,10 +35,11 @@ class SimpleTextFieldDef { * This can indicate which special input element is used, ... * */ public readonly explanation: string; - public inputmode?: string = undefined + protected inputmode?: string = undefined - constructor(explanation: string | BaseUIElement, name?: string) { - this.name = name ?? this.constructor.name.toLowerCase(); + 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) } @@ -52,22 +54,174 @@ class SimpleTextFieldDef { } } - public reformat(s: string, country?: () => string): string { + 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, + inputStyle?: string, + feedback?: UIEventSource + placeholder?: string | BaseUIElement, + country?: () => string, + location?: [number /*lat*/, number /*lon*/], + mapBackgroundLayer?: UIEventSource, + unit?: Unit, + args?: (string | number | boolean)[] // Extra arguments for the inputHelper, + feature?: any, + } = {}): InputElement { + + 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); + if (denom === undefined) { + return false; + } + const stripped = denom[0] + return self.isValid(stripped, options.country) + } + } else { + options["isValid"] = self.isValid; + } + + + options["inputMode"] = this.inputmode; + if (this.inputmode === "text") { + options["htmlType"] = "area" + } + + + const textfield = new TextField(options); + let input: InputElement = 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.defaultDenom) + 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); + 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(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 */ - public postprocess(s: string): string { + protected postprocess(s: string): string { return s } - public undoPostprocess(s: string): string { + protected undoPostprocess(s: string): string { return s; } - public inputHelper(value: UIEventSource, options?: { + protected inputHelper(value: UIEventSource, options?: { location: [number, number], mapBackgroundLayer?: UIEventSource, args: (string | number | boolean | any)[] @@ -76,36 +230,30 @@ class SimpleTextFieldDef { return undefined } - isValid(s: string, country: (() => string) | undefined): boolean { - return true; - } - - getFeedback(s: string) : Translation { - return undefined - } - } -class WikidataTextField extends SimpleTextFieldDef { +class WikidataTextField extends TextFieldDef { constructor() { - super(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"], - ["removePostfixes", "remove these snippets of text from the end of the passed string to search"], - ] - )]) - ]]), - 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 + 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"], + ["removePostfixes", "remove these snippets of text from the end of the passed string to search"], + ] + )]) + ]]), + 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 \`\`\` "freeform": { @@ -125,7 +273,7 @@ class WikidataTextField extends SimpleTextFieldDef { ] } \`\`\`` - ])); + ])); } @@ -184,10 +332,12 @@ class WikidataTextField extends SimpleTextFieldDef { } } -class OpeningHoursTextField extends SimpleTextFieldDef { +class OpeningHoursTextField extends TextFieldDef { constructor() { - super(new Combine([ + super( + "opening_hours", + new Combine([ "Has extra elements to easily input when a POI is opened.", new Title("Helper arguments"), new Table(["name", "doc"], @@ -214,8 +364,7 @@ class OpeningHoursTextField extends SimpleTextFieldDef { "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 )`"]), - "opening_hours"); +}` + "\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() { @@ -240,12 +389,12 @@ class OpeningHoursTextField extends SimpleTextFieldDef { } } -class UrlTextfieldDef extends SimpleTextFieldDef { +class UrlTextfieldDef extends TextFieldDef { inputmode: "url" constructor() { - super("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") + 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) { @@ -320,23 +469,23 @@ class UrlTextfieldDef extends SimpleTextFieldDef { } } -class StringTextField extends SimpleTextFieldDef { +class StringTextField extends TextFieldDef { constructor() { - super("A simple piece of text"); + super("string", "A simple piece of text"); } } -class TextTextField extends SimpleTextFieldDef { +class TextTextField extends TextFieldDef { inputmode: "text" constructor() { - super("A longer piece of text"); + super("text", "A longer piece of text"); } } -class DateTextField extends SimpleTextFieldDef { +class DateTextField extends TextFieldDef { constructor() { - super("A date with date picker"); + super("date", "A date with date picker"); } isValid = (str) => { @@ -362,21 +511,280 @@ class DateTextField extends SimpleTextFieldDef { } } -class DirectionTextField extends SimpleTextFieldDef { - inputMode = "numeric" + +class LengthTextField extends TextFieldDef { + inputMode: "decimal" constructor() { - super("A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)"); + super( + "decimal", "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\"]" + ) } isValid = (str) => { - str = "" + str; - return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360 + const t = Number(str) + return !isNaN(t) } inputHelper = (value, options) => { + 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() + options.location = <[number, number]>GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates + options.location.reverse() + } + + + const location = new UIEventSource({ + 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(args[1].split(",")) + ) + } + const li = new LengthInput(options?.mapBackgroundLayer, 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 + } + if (str.startsWith("mailto:")) { + str = str.substring("mailto:".length) + } + return EmailValidator.validate(str); + } + + reformat = str => { + if (str === undefined) { + return undefined + } + 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)) { @@ -401,190 +809,10 @@ class DirectionTextField extends SimpleTextFieldDef { } } -class LengthTextField extends SimpleTextFieldDef { - inputMode: "decimal" - - constructor() { - super( - "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\"]" - ) - } - - isValid = (str) => { - const t = Number(str) - return !isNaN(t) - } - - inputHelper = (value, options) => { - 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() - options.location = <[number, number]>GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates - options.location.reverse() - } - - const location = new UIEventSource({ - 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(args[1].split(",")) - ) - } - const li = new LengthInput(options.mapBackgroundLayer, location, value) - li.SetStyle("height: 20rem;") - return li; - } -} - -class IntTextField extends SimpleTextFieldDef { - inputMode = "numeric" - - constructor() { - super("A number"); - } - - isValid = (str) => { - str = "" + str; - return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) - } - - reformat = str => "" + Number(str) -} - -class NatTextField extends SimpleTextFieldDef { - inputMode = "numeric" - - constructor() { - super("A positive number or zero"); - } - - isValid = (str) => { - str = "" + str; - return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 - } - - reformat = str => "" + Number(str) -} - -class PNatTextField extends SimpleTextFieldDef { - inputmode = "numeric" - - constructor() { - super("A strict positive number"); - } - - isValid = (str) => { - str = "" + str; - return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) > 0 - } - - reformat = str => "" + Number(str) -} - -class FloatTextField extends SimpleTextFieldDef { - inputmode = "decimal" - - constructor() { - super("A decimal"); - } - - isValid = (str) => !isNaN(Number(str)) && !str.endsWith(".") && !str.endsWith(",") - - reformat = str => "" + Number(str) -} - -class PFloatTextField extends SimpleTextFieldDef { - inputmode = "decimal" - - constructor() { - super("A positive decimal (inclusive zero)"); - } - - isValid = (str) => !isNaN(Number(str)) && Number(str) >= 0 && !str.endsWith(".") && !str.endsWith(",") - - reformat = str => "" + Number(str) -} - -class EmailTextField extends SimpleTextFieldDef { - inputmode = "email" - - constructor() { - super("An email adress"); - } - - isValid = (str) => { - if (str.startsWith("mailto:")) { - str = str.substring("mailto:".length) - } - return EmailValidator.validate(str); - } - - reformat = str => { - if (str === undefined) { - return undefined - } - if (str.startsWith("mailto:")) { - str = str.substring("mailto:".length) - } - return str; - } -} - -class PhoneTextField extends SimpleTextFieldDef { - inputmode = "tel" - - constructor() { - super("A phone number"); - } - - isValid = (str, country: () => string) => { - if (str === undefined) { - return false; - } - if (str.startsWith("tel:")) { - str = str.substring("tel:".length) - } - return parsePhoneNumberFromString(str, (country())?.toUpperCase() as any)?.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 SimpleTextFieldDef { - constructor() { - super("Shows a color picker"); - } - - inputHelper = (value) => { - return new ColorPicker(value.map(color => { - return Utils.ColourNameToHex(color ?? ""); - }, [], str => Utils.HexToColourName(str))) - } -} export default class ValidatedTextField { - private static allTextfieldDefs: SimpleTextFieldDef[] = [ + private static AllTextfieldDefs: TextFieldDef[] = [ new StringTextField(), new TextTextField(), new DateTextField(), @@ -602,156 +830,14 @@ export default class ValidatedTextField { new OpeningHoursTextField(), new ColorTextField() ] - public static AllTypes: Map = ValidatedTextField.allTypesDict(); - - public static InputForType(type: string, options?: { - placeholder?: string | BaseUIElement, - value?: UIEventSource, - htmlType?: string, - textArea?: boolean, - inputMode?: string, - textAreaRows?: number, - isValid?: ((s: string, country: () => string) => boolean), - country?: () => string, - location?: [number /*lat*/, number /*lon*/], - mapBackgroundLayer?: UIEventSource, - unit?: Unit, - args?: (string | number | boolean)[] // Extra arguments for the inputHelper, - feature?: any, - inputStyle?: string - }): InputElement { - options = options ?? {}; - if (options.placeholder === undefined) { - options.placeholder = Translations.t.validation[type]?.description ?? type - } - const tp: SimpleTextFieldDef = ValidatedTextField.AllTypes.get(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; - } - if (options.unit) { - str = options.unit.stripUnitParts(str) - } - return isValidTp(str, country ?? options.country) && optValid(str, country ?? options.country); - } - } else { - 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; - if (tp.inputmode === "text") { - options.htmlType = "area" - } - - - let input: InputElement = new TextField(options); - if (tp.reformat && options.unit === undefined) { - 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 - - - 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.defaultDenom) - 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); - 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 = tp.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 (tp.postprocess !== undefined) { - input = new InputElementMap(input, - (a, b) => a === b, - tp.postprocess, - tp.undoPostprocess - ) - } - - return input; + public static allTypes: Map = ValidatedTextField.allTypesDict(); + public static ForType(type: string = "string"): TextFieldDef { + return ValidatedTextField.allTypes.get(type) } public static HelpText(): BaseUIElement { const explanations: BaseUIElement[] = - ValidatedTextField.allTextfieldDefs.map(type => + 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), @@ -761,12 +847,12 @@ export default class ValidatedTextField { } public static AvailableTypes(): string[] { - return ValidatedTextField.allTextfieldDefs.map(tp => tp.name) + return ValidatedTextField.AllTextfieldDefs.map(tp => tp.name) } - private static allTypesDict(): Map { - const types = new Map(); - for (const tp of ValidatedTextField.allTextfieldDefs) { + private static allTypesDict(): Map { + const types = new Map(); + for (const tp of ValidatedTextField.AllTextfieldDefs) { types[tp.name] = tp; types.set(tp.name, tp); } diff --git a/UI/Popup/NewNoteUi.ts b/UI/Popup/NewNoteUi.ts index 826737ce85..71fe2d7b9e 100644 --- a/UI/Popup/NewNoteUi.ts +++ b/UI/Popup/NewNoteUi.ts @@ -27,7 +27,7 @@ export default class NewNoteUi extends Toggle { const t = Translations.t.notes; const isCreated = new UIEventSource(false); state.LastClickLocation.addCallbackAndRun(_ => isCreated.setData(false)) // Reset 'isCreated' on every click - const text = ValidatedTextField.InputForType("text", { + const text = ValidatedTextField.ForType("text").ConstructInputElement({ value: LocalStorageSource.Get("note-text") }) text.SetClass("border rounded-sm border-grey-500") diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index 350ef4ad75..9193b89277 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -78,10 +78,12 @@ export default class TagRenderingQuestion extends Combine { .SetClass("question-text"), 3); + const feedback = new UIEventSource(undefined) const inputElement: InputElement = new VariableInputElement(applicableMappingsSrc.map(applicableMappings => - TagRenderingQuestion.GenerateInputElement(state, configuration, applicableMappings, applicableUnit, tags) + TagRenderingQuestion.GenerateInputElement(state, configuration, applicableMappings, applicableUnit, tags, feedback) )) + const save = () => { @@ -111,7 +113,7 @@ export default class TagRenderingQuestion extends Combine { const saveButton = new Combine([ options.saveButtonConstr(inputElement.GetValue()), ]) - + let bottomTags: BaseUIElement; if (options.bottomText !== undefined) { bottomTags = options.bottomText(inputElement.GetValue()) @@ -138,11 +140,17 @@ export default class TagRenderingQuestion extends Combine { super([ question, inputElement, - new Combine([options.cancelButton, - saveButton]).SetClass("flex w-full justify-end flex-wrap-reverse"), + new Combine([ + new VariableUiElement(feedback.map(t => t?.SetClass("alert") ?? "")).SetClass("grid justify-items-center"), + new Combine([ + new Combine([options.cancelButton]), + saveButton]).SetClass("flex justify-end flex-wrap-reverse") + + ]).SetClass("flex mt-2 justify-between") + , bottomTags, new Toggle(Translations.t.general.testing.SetClass("alert"), undefined, state.featureSwitchIsTesting) - ]) + ]) this.SetClass("question disable-links") @@ -154,13 +162,14 @@ export default class TagRenderingQuestion extends Combine { configuration: TagRenderingConfig, applicableMappings: { if: TagsFilter, then: any, ifnot?: TagsFilter, addExtraTags: Tag[] }[], applicableUnit: Unit, - tagsSource: UIEventSource) - : InputElement { + tagsSource: UIEventSource, + feedback: UIEventSource + ): InputElement { // FreeForm input will be undefined if not present; will already contain a special input element if applicable - const ff = TagRenderingQuestion.GenerateFreeform(state, configuration, applicableUnit, tagsSource); - + const ff = TagRenderingQuestion.GenerateFreeform(state, configuration, applicableUnit, tagsSource, feedback); + const hasImages = applicableMappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0 let inputEls: InputElement[]; @@ -359,25 +368,26 @@ export default class TagRenderingQuestion extends Combine { tagging = new And([tagging, ...mapping.addExtraTags]) } - + return new FixedInputElement( - TagRenderingQuestion.GenerateMappingContent(mapping, tagsSource, state) , + TagRenderingQuestion.GenerateMappingContent(mapping, tagsSource, state), tagging, (t0, t1) => t1.isEquivalent(t0)); } - - private static GenerateMappingContent( mapping: { + + private static GenerateMappingContent(mapping: { then: Translation, icon?: string - }, tagsSource: UIEventSource, state: FeaturePipelineState): BaseUIElement{ - const text = new SubstitutedTranslation(mapping.then, tagsSource, state) + }, tagsSource: UIEventSource, state: FeaturePipelineState): BaseUIElement { + const text = new SubstitutedTranslation(mapping.then, tagsSource, state) if (mapping.icon === undefined) { return text; } return new Combine([new Img(mapping.icon).SetClass("w-6 max-h-6 pr-2"), text]).SetClass("flex") } - private static GenerateFreeform(state, configuration: TagRenderingConfig, applicableUnit: Unit, tags: UIEventSource): InputElement { + private static GenerateFreeform(state, configuration: TagRenderingConfig, applicableUnit: Unit, tags: UIEventSource, feedback: UIEventSource) + : InputElement { const freeform = configuration.freeform; if (freeform === undefined) { return undefined; @@ -388,6 +398,9 @@ export default class TagRenderingQuestion extends Combine { if (string === "" || string === undefined) { return undefined; } + if (string.length >= 255) { + return undefined + } const tag = new Tag(freeform.key, string); @@ -418,18 +431,24 @@ export default class TagRenderingQuestion extends Combine { const tagsData = tags.data; const feature = state.allElements.ContainingFeatures.get(tagsData.id) - const input: InputElement = ValidatedTextField.InputForType(configuration.freeform.type, { - isValid: (str) => (str.length <= 255), + const input: InputElement = ValidatedTextField.ForType(configuration.freeform.type).ConstructInputElement({ country: () => tagsData._country, location: [tagsData._lat, tagsData._lon], mapBackgroundLayer: state.backgroundLayer, unit: applicableUnit, args: configuration.freeform.helperArgs, - feature: feature, - placeholder: configuration.freeform.placeholder + feature, + placeholder: configuration.freeform.placeholder, + feedback }); - + input.GetValue().setData(tagsData[freeform.key] ?? freeform.default); + + input.GetValue().addCallbackD(v => { + if(v.length >= 255){ + feedback.setData(Translations.t.validation.tooLong.Subs({count: v.length})) + } + }) let inputTagsFilter: InputElement = new InputElementMap( input, (a, b) => a === b || (a?.isEquivalent(b) ?? false), diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index c33404b1f4..1feb23b6bb 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -721,7 +721,7 @@ export default class SpecialVisualizations { constr: (state, tags, args) => { const t = Translations.t.notes; - const textField = ValidatedTextField.InputForType("text", {placeholder: t.addCommentPlaceholder}) + const textField = ValidatedTextField.ForType("text").ConstructInputElement({placeholder: t.addCommentPlaceholder}) textField.SetClass("rounded-l border border-grey") const txt = textField.GetValue() diff --git a/css/index-tailwind-output.css b/css/index-tailwind-output.css index 5685d6cf30..69d5dd456f 100644 --- a/css/index-tailwind-output.css +++ b/css/index-tailwind-output.css @@ -1304,6 +1304,10 @@ video { justify-content: space-between; } +.justify-items-center { + justify-items: center; +} + .gap-4 { gap: 1rem; } diff --git a/langs/en.json b/langs/en.json index e66c679587..6e050d6542 100644 --- a/langs/en.json +++ b/langs/en.json @@ -522,11 +522,26 @@ "string": { "description": "a piece of text" }, + "email": { + "feedback": "This is not a valid email address", + "noAt": "An email address should contain an @" +}, + "phone": { + "feedback": "This is not a valid phone number" + }, + "url": { + "feedback": "This is not a valid web address" + }, "pnat": { - "description": "a positive number" + "description": "a positive number", + "noZero": "Zero is not allowed" }, "nat": { - "description": "a positive number or zero" - } + "description": "a positive number or zero", + "mustBePositive": "This number should be positive", + "notANumber": "Enter a number", + "mustBeWhole": "Only whole numbers are allowed" + }, + "tooLong": "Text is to long, at most 255 characters are allowed. You do have {count} characters now" } } diff --git a/package.json b/package.json index c9b96ecee3..dc17c552c7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "start": "npm run generate:layeroverview && npm run ", "strt": "export NODE_OPTIONS=--max_old_space_size=8364 && parcel serve *.html UI/** Logic/** assets/*.json assets/svg/* assets/generated/* assets/layers/*/*.svg assets/layers/*/*.jpg assets/layers/*/*.png assets/layers/*/*.css assets/tagRenderings/*.json assets/themes/*/*.svg assets/themes/*/*.ttf assets/themes/*/*/*.ttf aassets/themes/*/*.otf assets/themes/*/*/*.otf ssets/themes/*/*.css assets/themes/*/*.jpg assets/themes/*/*.png vendor/* vendor/*/*", + "strttest": "export NODE_OPTIONS=--max_old_space_size=8364 && parcel serve test.html", "watch:css": "tailwindcss -i index.css -o css/index-tailwind-output.css --watch", "generate:css": "tailwindcss -i index.css -o css/index-tailwind-output.css", "test": "ts-node test/TestAll.ts", diff --git a/test.ts b/test.ts index b7fd7b5f73..fdd7f691e8 100644 --- a/test.ts +++ b/test.ts @@ -1,15 +1,33 @@ -import xml2js from 'xml2js'; -import {readFileSync} from "fs"; +import Combine from "./UI/Base/Combine"; +import ValidatedTextField from "./UI/Input/ValidatedTextField"; +import Title from "./UI/Base/Title"; +import {FixedUiElement} from "./UI/Base/FixedUiElement"; +import {VariableUiElement} from "./UI/Base/VariableUIElement"; +import {UIEventSource} from "./Logic/UIEventSource"; +import {Translation} from "./UI/i18n/Translation"; -const xml = "\n" + - "\n" + - " \n" + - " \n" + - " \n" + - "" +new Combine( + ValidatedTextField.AvailableTypes().map(key => { + let inp; + const feedback = new UIEventSource(undefined) + try { + inp = ValidatedTextField.ForType(key).ConstructInputElement({ + feedback, + country: () => "be", + + }); + } catch (e) { + console.error(e) + inp = new FixedUiElement(e).SetClass("alert") + } + + return new Combine([ + new Title(key), + inp, + new VariableUiElement(inp.GetValue()), + new VariableUiElement(feedback.map(v => v?.SetClass("alert"))) + ]); + } + ) +).AttachTo("maindiv") -xml2js.parseStringPromise(xml).then(svgResult => { - const svg = svgResult.svg - const builder = new xml2js.Builder(); - console.log(builder.buildObject(svg)) -})