mapcomplete/Models/ThemeConfig/FilterConfig.ts

221 lines
8.2 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 { TagConfigJson } from "./Json/TagConfigJson"
import { UIEventSource } from "../../Logic/UIEventSource"
import { QueryParameters } from "../../Logic/Web/QueryParameters"
import { Utils } from "../../Utils"
import { RegexTag } from "../../Logic/Tags/RegexTag"
import BaseUIElement from "../../UI/BaseUIElement"
import Table from "../../UI/Base/Table"
import Combine from "../../UI/Base/Combine"
export type FilterConfigOption = {
question: Translation
osmTags: TagsFilter | undefined
/* Only set if fields are present. Used to create `osmTags` (which are used to _actually_ filter) when the field is written*/
readonly originalTagsSpec: TagConfigJson
fields: { name: string; type: string }[]
}
export default class FilterConfig {
public readonly id: string
public readonly options: FilterConfigOption[]
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"
// Type is validated against 'ValidatedTextField' in Validation.ts, in ValidateFilterConfig
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) {
for (let ln in question.translations) {
const txt = question.translations[ln]
if (ln.startsWith("_")) {
continue
}
if (txt.indexOf("{" + field.name + "}") < 0) {
throw (
"Error in filter with fields at " +
context +
".question." +
ln +
": The question text should contain every field, but it doesn't contain `{" +
field +
"}`: " +
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 == ".+" ||
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(layerId: string): UIEventSource<undefined | number | string> {
let defaultValue = ""
if (this.options.length > 1) {
defaultValue = "" + (this.defaultSelection ?? 0)
} else if (this.options[0].fields?.length > 0) {
defaultValue = "{}"
} else {
// Only a single option
if (this.defaultSelection === 0) {
defaultValue = "true"
} else {
defaultValue = "false"
}
}
const qp = QueryParameters.GetQueryParameter(
`filter-${layerId}-${this.id}`,
defaultValue,
"State of filter " + this.id
)
if (this.options.length > 1) {
// 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 parsed
},
[],
(n) => "" + n
)
}
const option = this.options[0]
if (option.fields.length > 0) {
return qp
}
return qp.sync(
(str) => {
// Only a single option exists here
if (str === "true") {
return 0
}
return undefined
},
[],
(n) => (n === undefined ? "false" : "true")
)
}
public GenerateDocs(): BaseUIElement {
const hasField = this.options.some((opt) => opt.fields?.length > 0)
return new Table(
Utils.NoNull(["id", "question", "osmTags", hasField ? "fields" : undefined]),
this.options.map((opt, i) => {
const isDefault = this.options.length > 1 && (this.defaultSelection ?? 0) == i
return Utils.NoNull([
this.id + "." + i,
isDefault
? new Combine([opt.question.SetClass("font-bold"), "(default)"])
: opt.question,
opt.osmTags?.asHumanString(false, false, {}) ?? "",
opt.fields?.length > 0
? new Combine(opt.fields.map((f) => f.name + " (" + f.type + ")"))
: undefined,
])
})
)
}
}