forked from MapComplete/MapComplete
		
	Add date validation, add url validation (and tracker cleaning), add date picker, close #44
This commit is contained in:
		
							parent
							
								
									78368ef543
								
							
						
					
					
						commit
						415052af8a
					
				
					 8 changed files with 213 additions and 48 deletions
				
			
		|  | @ -95,10 +95,10 @@ export class Tag extends TagsFilter { | |||
|         this.key = key | ||||
|         this.value = value | ||||
|         if(key === undefined || key === ""){ | ||||
|             throw "Invalid key"; | ||||
|             throw "Invalid key: undefined or empty"; | ||||
|         } | ||||
|         if(value === undefined){ | ||||
|             throw "Invalid value"; | ||||
|             throw "Invalid value: value is undefined"; | ||||
|         } | ||||
|         if(value === "*"){ | ||||
|          console.warn(`Got suspicious tag ${key}=*   ; did you mean ${key}~* ?`) | ||||
|  |  | |||
							
								
								
									
										35
									
								
								UI/Input/CombinedInputElement.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								UI/Input/CombinedInputElement.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| 
 | ||||
| export default class CombinedInputElement<T> extends InputElement<T> { | ||||
|     private readonly _a: InputElement<T>; | ||||
|     private readonly _b: UIElement; | ||||
|     private readonly _combined: UIElement; | ||||
|     public readonly IsSelected: UIEventSource<boolean>; | ||||
| 
 | ||||
|     constructor(a: InputElement<T>, b: InputElement<T>) { | ||||
|         super(); | ||||
|         this._a = a; | ||||
|         this._b = b; | ||||
|         this.IsSelected = this._a.IsSelected.map((isSelected) => { | ||||
|             return isSelected || b.IsSelected.data | ||||
|         }, [b.IsSelected]) | ||||
|         this._combined = new Combine([this._a, this._b]); | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<T> { | ||||
|         return this._a.GetValue(); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._combined.Render(); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     IsValid(t: T): boolean { | ||||
|         return this._a.IsValid(t); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										60
									
								
								UI/Input/SimpleDatePicker.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								UI/Input/SimpleDatePicker.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,60 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| export default class SimpleDatePicker extends InputElement<string> { | ||||
| 
 | ||||
|     private readonly value: UIEventSource<string> | ||||
| 
 | ||||
|     constructor( | ||||
|         value?: UIEventSource<string> | ||||
|     ) { | ||||
|         super(); | ||||
|         this.value = value ?? new UIEventSource<string>(undefined); | ||||
|         const self = this; | ||||
|         this.value.addCallbackAndRun(v => { | ||||
|             if(v === undefined){ | ||||
|                 return; | ||||
|             } | ||||
|             self.SetValue(v); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return `<span id="${this.id}"><input type='date' id='date-${this.id}'></span>`; | ||||
|     } | ||||
|      | ||||
|     private SetValue(date: string){ | ||||
|         const field = document.getElementById("date-" + this.id); | ||||
|         if (field === undefined || field === null) { | ||||
|             return; | ||||
|         } | ||||
|         // @ts-ignore
 | ||||
|         field.value = date; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerUpdate() { | ||||
|         const field = document.getElementById("date-" + this.id); | ||||
|         if (field === undefined || field === null) { | ||||
|             return; | ||||
|         } | ||||
|         const self = this; | ||||
|         field.oninput = () => { | ||||
|             // Already in YYYY-MM-DD value! 
 | ||||
|             // @ts-ignore
 | ||||
|             self.value.setData(field.value); | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<string> { | ||||
|         return this.value; | ||||
|     } | ||||
| 
 | ||||
|     IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
| 
 | ||||
|     IsValid(t: string): boolean { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -11,6 +11,8 @@ export class TextField extends InputElement<string> { | |||
|     private readonly _isArea: boolean; | ||||
|     private readonly _textAreaRows: number; | ||||
| 
 | ||||
|     private readonly _isValid: (string) => boolean; | ||||
| 
 | ||||
|     constructor(options?: { | ||||
|         placeholder?: string | UIElement, | ||||
|         value?: UIEventSource<string>, | ||||
|  | @ -25,9 +27,8 @@ export class TextField extends InputElement<string> { | |||
|         this._isArea = options.textArea ?? false; | ||||
|         this.value = options?.value ?? new UIEventSource<string>(undefined); | ||||
| 
 | ||||
|         // @ts-ignore
 | ||||
|         this._fromString = options.fromString ?? ((str) => (str)) | ||||
|         this._textAreaRows = options.textAreaRows; | ||||
|         this._isValid = options.isValid ?? ((str) => true); | ||||
| 
 | ||||
|         this._placeholder = Translations.W(options.placeholder ?? ""); | ||||
|         this.ListenTo(this._placeholder._source); | ||||
|  | @ -36,16 +37,13 @@ export class TextField extends InputElement<string> { | |||
|             self.IsSelected.setData(true) | ||||
|         }); | ||||
|         this.value.addCallback((t) => { | ||||
|             const field = document.getElementById(this.id); | ||||
|             const field = document.getElementById("txt-"+this.id); | ||||
|             if (field === undefined || field === null) { | ||||
|                 return; | ||||
|             } | ||||
|             if (options.isValid) { | ||||
|                 field.className = options.isValid(t) ? "" : "invalid"; | ||||
|             } | ||||
|             field.className = self.IsValid(t) ? "" : "invalid"; | ||||
| 
 | ||||
|             if (t === undefined || t === null) { | ||||
|                 // @ts-ignore
 | ||||
|                 return; | ||||
|             } | ||||
|             // @ts-ignore
 | ||||
|  | @ -77,11 +75,18 @@ export class TextField extends InputElement<string> { | |||
|         const self = this; | ||||
|         field.oninput = () => { | ||||
|             // @ts-ignore
 | ||||
|             var endDistance = field.value.length - field.selectionEnd; | ||||
|             const endDistance = field.value.length - field.selectionEnd; | ||||
|             // @ts-ignore
 | ||||
|             self.value.setData(field.value); | ||||
|             const val: string = field.value; | ||||
|             if (!self.IsValid(val)) { | ||||
|                 self.value.setData(undefined); | ||||
|             } else { | ||||
|                 self.value.setData(val); | ||||
|             } | ||||
|             // Setting the value might cause the value to be set again. We keep the distance _to the end_ stable, as phone number formatting might cause the start to change
 | ||||
|             // See https://github.com/pietervdvn/MapComplete/issues/103
 | ||||
|             // We reread the field value - it might have changed!
 | ||||
|             // @ts-ignore
 | ||||
|             self.SetCursorPosition(field.value.length - endDistance); | ||||
|         }; | ||||
| 
 | ||||
|  | @ -119,7 +124,10 @@ export class TextField extends InputElement<string> { | |||
|     } | ||||
| 
 | ||||
|     IsValid(t: string): boolean { | ||||
|         return !(t === undefined || t === null); | ||||
|         if (t === undefined || t === null) { | ||||
|             return false | ||||
|         } | ||||
|         return this._isValid(t); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -6,6 +6,16 @@ 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"; | ||||
| 
 | ||||
| interface TextFieldDef { | ||||
|     name: string, | ||||
|     explanation: string, | ||||
|     isValid: ((s: string, country?: string) => boolean), | ||||
|     reformat?: ((s: string, country?: string) => string), | ||||
|     inputHelper?: (value:UIEventSource<string>) => InputElement<string> | ||||
| } | ||||
| 
 | ||||
| export default class ValidatedTextField { | ||||
| 
 | ||||
|  | @ -13,12 +23,8 @@ export default class ValidatedTextField { | |||
|     private static tp(name: string, | ||||
|                       explanation: string, | ||||
|                       isValid?: ((s: string, country?: string) => boolean), | ||||
|                       reformat?: ((s: string, country?: string) => string)): { | ||||
|         name: string, | ||||
|         explanation: string, | ||||
|         isValid: ((s: string, country?: string) => boolean), | ||||
|         reformat?: ((s: string, country?: string) => string) | ||||
|     } { | ||||
|                       reformat?: ((s: string, country?: string) => string), | ||||
|                       inputHelper?: (value: UIEventSource<string>) => InputElement<string>): TextFieldDef { | ||||
| 
 | ||||
|         if (isValid === undefined) { | ||||
|             isValid = () => true; | ||||
|  | @ -33,17 +39,36 @@ export default class ValidatedTextField { | |||
|             name: name, | ||||
|             explanation: explanation, | ||||
|             isValid: isValid, | ||||
|             reformat: reformat | ||||
|             reformat: reformat, | ||||
|             inputHelper: inputHelper | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static tpList = [ | ||||
|     public static tpList: TextFieldDef[] = [ | ||||
|         ValidatedTextField.tp( | ||||
|             "string", | ||||
|             "A basic string"), | ||||
|         ValidatedTextField.tp( | ||||
|             "date", | ||||
|             "A 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"), | ||||
|  | @ -82,7 +107,30 @@ export default class ValidatedTextField { | |||
|             (str) => EmailValidator.validate(str)), | ||||
|         ValidatedTextField.tp( | ||||
|             "url", | ||||
|             "A 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"] | ||||
|                     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", | ||||
|  | @ -117,12 +165,30 @@ export default class ValidatedTextField { | |||
| 
 | ||||
|     public static AllTypes = ValidatedTextField.allTypesDict(); | ||||
| 
 | ||||
|     public static InputForType(type: string): TextField { | ||||
|     public static InputForType(type: string, options?: { | ||||
|         placeholder?: string | UIElement, | ||||
|         value?: UIEventSource<string>, | ||||
|         textArea?: boolean, | ||||
|         textAreaRows?: number, | ||||
|         isValid?: ((s: string) => boolean) | ||||
|     }): InputElement<string> { | ||||
|         options = options ?? {}; | ||||
|         options.placeholder = options.placeholder ?? type; | ||||
|         const tp: TextFieldDef = ValidatedTextField.AllTypes[type] | ||||
|         let isValid = tp.isValid; | ||||
|         if (options.isValid) { | ||||
|             const optValid = options.isValid; | ||||
|             isValid = (str, country) => { | ||||
|                 return ValidatedTextField.AllTypes[type](str, country) && optValid(str); | ||||
|             } | ||||
|         } | ||||
|         options.isValid = isValid; | ||||
| 
 | ||||
|         return new TextField({ | ||||
|             placeholder: type, | ||||
|             isValid: ValidatedTextField.AllTypes[type] | ||||
|         }) | ||||
|         let input: InputElement<string> = new TextField(options); | ||||
|         if (tp.inputHelper) { | ||||
|             input = new CombinedInputElement(input, tp.inputHelper(input.GetValue())); | ||||
|         } | ||||
|         return input; | ||||
|     } | ||||
| 
 | ||||
|     public static NumberInput(type: string = "int", extraValidation: (number: Number) => boolean = undefined): InputElement<number> { | ||||
|  | @ -181,13 +247,19 @@ export default class ValidatedTextField { | |||
| 
 | ||||
|     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) | ||||
|     }): InputElement<T> { | ||||
|         const textField = new TextField(options); | ||||
|         let textField: InputElement<string>; | ||||
|         if (options.type) { | ||||
|             textField = ValidatedTextField.InputForType(options.type); | ||||
|         } else { | ||||
|             textField = new TextField(options); | ||||
|         } | ||||
|         return new InputElementMap( | ||||
|             textField, (a, b) => a === b, | ||||
|             fromString, toString | ||||
|  |  | |||
|  | @ -325,10 +325,7 @@ export class TagRendering extends UIElement implements TagDependantUIElement { | |||
|             throw "Unkown type: "+type; | ||||
|         } | ||||
| 
 | ||||
|         let isValid = ValidatedTextField.AllTypes[type].isValid; | ||||
|         if (isValid === undefined) { | ||||
|             isValid = () => true; | ||||
|         } | ||||
|          | ||||
|         let formatter = ValidatedTextField.AllTypes[type].reformat ?? ((str) => str); | ||||
| 
 | ||||
|         const pickString = | ||||
|  | @ -336,17 +333,9 @@ export class TagRendering extends UIElement implements TagDependantUIElement { | |||
|                 if (string === "" || string === undefined) { | ||||
|                     return undefined; | ||||
|                 } | ||||
|                 if (!isValid(string, this._source.data._country)) { | ||||
|                     return undefined; | ||||
|                 } | ||||
| 
 | ||||
| 
 | ||||
|                 const tag = new Tag(freeform.key, formatter(string, this._source.data._country)); | ||||
| 
 | ||||
|                 if (tag.value.length > 255) { | ||||
|                     return undefined; // Too long
 | ||||
|                 } | ||||
| 
 | ||||
|                 if (freeform.extraTags === undefined) { | ||||
|                     return tag; | ||||
|                 } | ||||
|  | @ -374,7 +363,8 @@ export class TagRendering extends UIElement implements TagDependantUIElement { | |||
| 
 | ||||
|         return ValidatedTextField.Mapped(pickString, toString, { | ||||
|             placeholder: this._freeform.placeholder, | ||||
|             isValid: isValid, | ||||
|             type: type, | ||||
|             isValid: (str) => (str.length <= 255), | ||||
|             textArea: isTextArea | ||||
|         }) | ||||
|     } | ||||
|  |  | |||
|  | @ -83,7 +83,7 @@ export class UserBadge extends UIElement { | |||
| 
 | ||||
|         let dryrun = ""; | ||||
|         if (user.dryRun) { | ||||
|             dryrun = " <span class='alert'>TESTING</span>"; | ||||
|             dryrun = new FixedUiElement("TESTING").SetClass("alert").Render(); | ||||
|         } | ||||
| 
 | ||||
|         if (user.home !== undefined) { | ||||
|  | @ -98,7 +98,7 @@ export class UserBadge extends UIElement { | |||
|         const settings = | ||||
|             "<a href='https://www.openstreetmap.org/user/" + encodeURIComponent(user.name) + "/account' target='_blank'>" + | ||||
|             "<img class='small-userbadge-icon' src='./assets/gear.svg' alt='settings'>" + | ||||
|             "</a> "; | ||||
|             "</a>"; | ||||
| 
 | ||||
|         const userIcon = "<a href='https://www.openstreetmap.org/user/" + encodeURIComponent(user.name) + "' target='_blank'><img id='profile-pic' src='" + user.img + "' alt='profile-pic'/></a>"; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										4
									
								
								test.ts
									
										
									
									
									
								
							
							
						
						
									
										4
									
								
								test.ts
									
										
									
									
									
								
							|  | @ -1,8 +1,8 @@ | |||
| import ValidatedTextField from "./UI/Input/ValidatedTextField"; | ||||
| import {VariableUiElement} from "./UI/Base/VariableUIElement"; | ||||
| import SimpleDatePicker from "./UI/Input/SimpleDatePicker"; | ||||
| 
 | ||||
| 
 | ||||
| const vtf= ValidatedTextField.KeyInput(true); | ||||
| const vtf=new SimpleDatePicker(); | ||||
| vtf.AttachTo('maindiv') | ||||
| vtf.GetValue().addCallback(console.log) | ||||
| new VariableUiElement(vtf.GetValue().map(n => ""+n)).AttachTo("extradiv") | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue