forked from MapComplete/MapComplete
		
	
		
			
				
	
	
		
			217 lines
		
	
	
		
			No EOL
		
	
	
		
			8.1 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			217 lines
		
	
	
		
			No EOL
		
	
	
		
			8.1 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import {Translation} from "../../UI/i18n/Translation";
 | |
| import {TagsFilter} from "../../Logic/Tags/TagsFilter";
 | |
| import FilterConfigJson from "./Json/FilterConfigJson";
 | |
| import Translations from "../../UI/i18n/Translations";
 | |
| import {TagUtils} from "../../Logic/Tags/TagUtils";
 | |
| import ValidatedTextField from "../../UI/Input/ValidatedTextField";
 | |
| import {AndOrTagConfigJson} from "./Json/TagConfigJson";
 | |
| import {UIEventSource} from "../../Logic/UIEventSource";
 | |
| import {FilterState} from "../FilteredLayer";
 | |
| import {QueryParameters} from "../../Logic/Web/QueryParameters";
 | |
| import {Utils} from "../../Utils";
 | |
| import {RegexTag} from "../../Logic/Tags/RegexTag";
 | |
| 
 | |
| export default class FilterConfig {
 | |
|     public readonly id: string
 | |
|     public readonly options: {
 | |
|         question: Translation;
 | |
|         osmTags: TagsFilter | undefined;
 | |
|         originalTagsSpec: string | AndOrTagConfigJson
 | |
|         fields: { name: string, type: string }[]
 | |
|     }[];
 | |
|     public readonly defaultSelection? : number
 | |
| 
 | |
|     constructor(json: FilterConfigJson, context: string) {
 | |
|         if (json.options === undefined) {
 | |
|             throw `A filter without options was given at ${context}`
 | |
|         }
 | |
|         if (json.id === undefined) {
 | |
|             throw `A filter without id was found at ${context}`
 | |
|         }
 | |
|         if (json.id.match(/^[a-zA-Z0-9_-]*$/) === null) {
 | |
|             throw `A filter with invalid id was found at ${context}. Ids should only contain letters, numbers or - _`
 | |
|         }
 | |
| 
 | |
|         if (json.options.map === undefined) {
 | |
|             throw `A filter was given where the options aren't a list at ${context}`
 | |
|         }
 | |
|         this.id = json.id;
 | |
|         let defaultSelection : number = undefined
 | |
|         this.options = json.options.map((option, i) => {
 | |
|             const ctx = `${context}.options.${i}`;
 | |
|             const question = Translations.T(
 | |
|                 option.question,
 | |
|                 `${ctx}.question`
 | |
|             );
 | |
|             let osmTags: undefined | TagsFilter = undefined;
 | |
|             if ((option.fields?.length ?? 0) == 0 && option.osmTags !== undefined) {
 | |
|                 osmTags = TagUtils.Tag(
 | |
|                     option.osmTags,
 | |
|                     `${ctx}.osmTags`
 | |
|                 );
 | |
|                 FilterConfig.validateSearch(osmTags, ctx)
 | |
|             }
 | |
|             if (question === undefined) {
 | |
|                 throw `Invalid filter: no question given at ${ctx}`
 | |
|             }
 | |
| 
 | |
|             const fields: { name: string, type: string }[] = ((option.fields) ?? []).map((f, i) => {
 | |
|                 const type = f.type ?? "string"
 | |
|                 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}]`
 | |
|                 }
 | |
|                 return {
 | |
|                     name: f.name,
 | |
|                     type
 | |
|                 }
 | |
|             })
 | |
| 
 | |
|             for (const field of fields) {
 | |
|                 question.OnEveryLanguage((txt, language) => {
 | |
|                     if(txt.indexOf("{"+field.name+"}")<0){
 | |
|                         throw "Error in filter with fields at "+context+".question."+language+": The question text should contain every field, but it doesn't contain `{"+field+"}`: "+txt
 | |
|                     }
 | |
|                     return txt
 | |
|                 })
 | |
|             }
 | |
| 
 | |
|             if(option.default){
 | |
|                 if(defaultSelection === undefined){
 | |
|                     defaultSelection = i;
 | |
|                 }else{
 | |
|                     throw `Invalid filter: multiple filters are set as default, namely ${i} and ${defaultSelection} at ${context}`
 | |
|                 }
 | |
|             }
 | |
|             
 | |
|             if(option.osmTags !== undefined){
 | |
|                 FilterConfig.validateSearch(TagUtils.Tag(option.osmTags), ctx)
 | |
|             }
 | |
| 
 | |
|             return {question: question, osmTags: osmTags, fields, originalTagsSpec: option.osmTags};
 | |
|         });
 | |
|         
 | |
|         this.defaultSelection = defaultSelection 
 | |
| 
 | |
|         if (this.options.some(o => o.fields.length > 0) && this.options.length > 1) {
 | |
|             throw `Invalid filter at ${context}: a filter with textfields should only offer a single option.`
 | |
|         }
 | |
| 
 | |
|         if (this.options.length > 1 && this.options[0].osmTags !== undefined) {
 | |
|             throw "Error in " + context + "." + this.id + ": the first option of a multi-filter should always be the 'reset' option and not have any filters"
 | |
|         }
 | |
|         
 | |
|         
 | |
|     }
 | |
| 
 | |
|     private static validateSearch(osmTags: TagsFilter, ctx: string){
 | |
|     osmTags.visit(t => {
 | |
|             if (!(t instanceof RegexTag)) {
 | |
|                 return;
 | |
|             }
 | |
|             if(typeof t.value == "string"){
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             if(t.value.source == '^..*$' || t.value.source == '^[\\s\\S][\\s\\S]*$' /*Compiled regex with 'm'*/){
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             if(!t.value.ignoreCase) {
 | |
|                 throw `At ${ctx}: The filter for key '${t.key}' uses a regex '${t.value}', but you should use a case invariant regex with ~i~ instead, as search should be case insensitive`
 | |
|             }
 | |
| 
 | |
|         })
 | |
|     }
 | |
|     
 | |
|     public initState(): UIEventSource<FilterState> {
 | |
| 
 | |
|         function reset(state: FilterState): string {
 | |
|             if (state === undefined) {
 | |
|                 return ""
 | |
|             }
 | |
|             return "" + state.state
 | |
|         }
 | |
| 
 | |
|         let defaultValue = ""
 | |
|         if(this.options.length > 1){
 | |
|             defaultValue = ""+(this.defaultSelection ?? 0)
 | |
|         }else{
 | |
|             // Only a single option
 | |
|             if(this.defaultSelection === 0){
 | |
|                 defaultValue = "true"
 | |
|             }
 | |
|         }
 | |
|         const qp = QueryParameters.GetQueryParameter("filter-" + this.id, defaultValue, "State of filter " + this.id)
 | |
| 
 | |
|         if (this.options.length > 1) {
 | |
|             // This is a multi-option filter; state should be a number which selects the correct entry
 | |
|             const possibleStates: FilterState [] = this.options.map((opt, i) => ({
 | |
|                 currentFilter: opt.osmTags,
 | |
|                 state: i
 | |
|             }))
 | |
| 
 | |
|             // We map the query parameter for this case
 | |
|             return qp.sync(str => {
 | |
|                 const parsed = Number(str)
 | |
|                 if (isNaN(parsed)) {
 | |
|                     // Nope, not a correct number!
 | |
|                     return undefined
 | |
|                 }
 | |
|                 return possibleStates[parsed]
 | |
|             }, [], reset)
 | |
|         }
 | |
| 
 | |
| 
 | |
|         const option = this.options[0]
 | |
| 
 | |
|         if (option.fields.length > 0) {
 | |
|             return qp.sync(str => {
 | |
|                 // There are variables in play!
 | |
|                 // str should encode a json-hash
 | |
|                 try {
 | |
|                     const props = JSON.parse(str)
 | |
| 
 | |
|                     const origTags = option.originalTagsSpec
 | |
|                     const rewrittenTags = Utils.WalkJson(origTags,
 | |
|                         v => {
 | |
|                             if (typeof v !== "string") {
 | |
|                                 return v
 | |
|                             }
 | |
|                             for (const key in props) {
 | |
|                                 v = (<string>v).replace("{" + key + "}", props[key])
 | |
|                             }
 | |
|                             return v
 | |
|                         }
 | |
|                     )
 | |
|                     const parsed = TagUtils.Tag(rewrittenTags)
 | |
|                     return <FilterState>{
 | |
|                         currentFilter: parsed,
 | |
|                         state: str
 | |
|                     }
 | |
|                 } catch (e) {
 | |
|                     return undefined
 | |
|                 }
 | |
| 
 | |
|             }, [], reset)
 | |
|         }
 | |
| 
 | |
|         // The last case is pretty boring: it is checked or it isn't
 | |
|         const filterState: FilterState = {
 | |
|             currentFilter: option.osmTags,
 | |
|             state: "true"
 | |
|         }
 | |
|         return qp.sync(
 | |
|             str => {
 | |
|                 // Only a single option exists here
 | |
|                 if (str === "true") {
 | |
|                     return filterState
 | |
|                 }
 | |
|                 return undefined
 | |
|             }, [],
 | |
|             reset
 | |
|         )
 | |
|     }
 | |
| } |