TagRenderingConfig: fix 'leftovers' of multi-answer freeform, generateDocs now generates Markdown instead of a BaseUIElement, add 'postfixDistinguished'-option for 'charge'-key

This commit is contained in:
Pieter Vander Vennet 2024-06-06 03:16:36 +02:00
parent 53ef1b947d
commit 3a69157d10
4 changed files with 138 additions and 92 deletions

View file

@ -867,6 +867,7 @@
}, },
"website", "website",
{ {
"id": "charge_cost_rewritten",
"rewrite": { "rewrite": {
"sourceString": [ "sourceString": [
"{product_key}", "{product_key}",
@ -902,7 +903,9 @@
] ]
] ]
}, },
"renderings": { "renderings": [
{
"id": "charge_{product_key}",
"question": { "question": {
"en": "How much does a {product_name} cost?", "en": "How much does a {product_name} cost?",
"ca": "Quant costa {product_name}?", "ca": "Quant costa {product_name}?",
@ -914,22 +917,24 @@
"pt": "Quanto custa {product_name}?" "pt": "Quanto custa {product_name}?"
}, },
"render": { "render": {
"en": "{product_name} costs {charge:{product_key}}", "en": "{product_name} costs {charge}",
"ca": "{product_name} costa {charge:{product_key}}", "ca": "{product_name} costa {charge}",
"de": "{product_name} kostet {charge:{product_key}}", "de": "{product_name} kostet {charge}",
"cs": "{product_name} {charge:{product_key}}", "cs": "{product_name} {charge}",
"nl": "{product_name} kost {charge:{product_key}}", "nl": "{product_name} kost {charge}",
"pt_BR": "{product_name} custa {charge:{product_key}}", "pt_BR": "{product_name} custa {charge}",
"es": "{product_name} cuesta {charge:{product_key}}", "es": "{product_name} cuesta {charge}",
"pt": "{product_name} custa {charge:{product_key}}" "pt": "{product_name} custa {charge}"
}, },
"freeform": { "freeform": {
"key": "charge:{product_key}", "key": "charge",
"type": "currency" "type": "currency",
"inline": true,
"postfixDistinguished": "{product_key}"
}, },
"condition": "vending~.*{product_key}.*", "condition": "vending~.*{product_key}.*"
"id": "charge_{product_key}"
} }
]
}, },
{ {
"id": "operational_status", "id": "operational_status",

View file

@ -264,7 +264,22 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs
* ifunset: The question will be considered answered if any value is set for the key * ifunset: The question will be considered answered if any value is set for the key
* group: expert * group: expert
*/ */
invalidValues?: TagConfigJson invalidValues?: TagConfigJson,
/**
* question: If this key shared and distinguished by a postfix, what is the postfix?
* This option is used specifically for `charge`, where the cost is indicated with `/item`.
*
* For example, a vending machine might sell `bicycle_tube`.
* Setting this value to `bicycle_tube`, then answering this question will set `charge= €XX/bicycle_tube`.
* If charge did already contain another value, e.g. `charge= €YY/some_item; €ZZ/other_item`, then `€XX/bicycle_tube`will be added.
* Note: those values are sorted alphabetically
* Note: no need to add the `/`
*
* ifunset: Don't distinguish by postfix
* group: expert
*/
postfixDistinguished?: string
} }
/** /**

View file

@ -5,13 +5,8 @@ import { TagUtils, UploadableTag } from "../../Logic/Tags/TagUtils"
import { And } from "../../Logic/Tags/And" import { And } from "../../Logic/Tags/And"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { Tag } from "../../Logic/Tags/Tag" import { Tag } from "../../Logic/Tags/Tag"
import BaseUIElement from "../../UI/BaseUIElement"
import Combine from "../../UI/Base/Combine"
import Title from "../../UI/Base/Title"
import Link from "../../UI/Base/Link" import Link from "../../UI/Base/Link"
import List from "../../UI/Base/List"
import { MappingConfigJson, QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson" import { MappingConfigJson, QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson"
import { FixedUiElement } from "../../UI/Base/FixedUiElement"
import Validators, { ValidatorType } from "../../UI/InputElement/Validators" import Validators, { ValidatorType } from "../../UI/InputElement/Validators"
import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson" import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson"
import { RegexTag } from "../../Logic/Tags/RegexTag" import { RegexTag } from "../../Logic/Tags/RegexTag"
@ -19,9 +14,7 @@ import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import NameSuggestionIndex from "../../Logic/Web/NameSuggestionIndex" import NameSuggestionIndex from "../../Logic/Web/NameSuggestionIndex"
import { GeoOperations } from "../../Logic/GeoOperations" import { GeoOperations } from "../../Logic/GeoOperations"
import { Feature } from "geojson" import { Feature } from "geojson"
import MarkdownUtils from "../../Utils/MarkdownUtils"
export interface Icon {
}
export interface Mapping { export interface Mapping {
readonly if: UploadableTag readonly if: UploadableTag
@ -72,6 +65,7 @@ export default class TagRenderingConfig {
readonly addExtraTags: UploadableTag[] readonly addExtraTags: UploadableTag[]
readonly inline: boolean readonly inline: boolean
readonly default?: string readonly default?: string
readonly postfixDistinguished?: string
} }
public readonly multiAnswer: boolean public readonly multiAnswer: boolean
@ -201,7 +195,8 @@ export default class TagRenderingConfig {
TagUtils.ParseUploadableTag(tg, `${context}.extratag[${i}]`) TagUtils.ParseUploadableTag(tg, `${context}.extratag[${i}]`)
) ?? [], ) ?? [],
inline: json.freeform.inline ?? false, inline: json.freeform.inline ?? false,
default: json.freeform.default default: json.freeform.default,
postfixDistinguished: json.freeform.postfixDistinguished?.trim()
} }
if (json.freeform["extraTags"] !== undefined) { if (json.freeform["extraTags"] !== undefined) {
throw `Freeform.extraTags is defined. This should probably be 'freeform.addExtraTag' (at ${context})` throw `Freeform.extraTags is defined. This should probably be 'freeform.addExtraTag' (at ${context})`
@ -218,6 +213,14 @@ export default class TagRenderingConfig {
throw `If you use a freeform key 'questions', the ID must be 'questions' too to trigger the special behaviour. The current id is '${this.id}' (at ${context})` throw `If you use a freeform key 'questions', the ID must be 'questions' too to trigger the special behaviour. The current id is '${this.id}' (at ${context})`
} }
} }
if (this.freeform.postfixDistinguished) {
if (this.multiAnswer) {
throw "At " + context + ": a postfixDistinguished-value cannot be used with a multiAnswer"
}
if (this.freeform.postfixDistinguished.startsWith("/")) {
throw "At " + context + ": a postfixDistinguished-value should not start with `/`. This will be inserted automatically"
}
}
// freeform.type is validated in Validation.ts so that we don't need ValidatedTextFields here // freeform.type is validated in Validation.ts so that we don't need ValidatedTextFields here
if (this.freeform.addExtraTags) { if (this.freeform.addExtraTags) {
@ -466,9 +469,9 @@ export default class TagRenderingConfig {
// A flag to check that the freeform key isn't matched multiple times // A flag to check that the freeform key isn't matched multiple times
// If it is undefined, it is "used" already, or at least we don't have to check for it anymore // If it is undefined, it is "used" already, or at least we don't have to check for it anymore
const freeformKeyDefined = this.freeform?.key !== undefined const freeformKeyDefined = this.freeform?.key !== undefined
const usedFreeformValues = new Set<string>()
// We run over all the mappings first, to check if the mapping matches // We run over all the mappings first, to check if the mapping matches
const applicableMappings: { const applicableMappings: {
if?: TagsFilter
then: TypedTranslation<Record<string, string>> then: TypedTranslation<Record<string, string>>
img?: string img?: string
}[] = Utils.NoNull( }[] = Utils.NoNull(
@ -477,23 +480,23 @@ export default class TagRenderingConfig {
return mapping return mapping
} }
if (TagUtils.MatchesMultiAnswer(mapping.if, tags)) { if (TagUtils.MatchesMultiAnswer(mapping.if, tags)) {
if (freeformKeyDefined && mapping.if.isUsableAsAnswer()) {
// THe freeform key is defined: what value does it use though?
// We mark the value to see if we have any leftovers
const value = mapping.if
.asChange({})
.find((kv) => kv.k === this.freeform.key).v
usedFreeformValues.add(value)
}
return mapping return mapping
} }
return undefined return undefined
}) })
) )
if (freeformKeyDefined && tags[this.freeform.key] !== undefined) { if (freeformKeyDefined && tags[this.freeform.key] !== undefined) {
const usedFreeformValues = new Set<string>(
applicableMappings
?.flatMap(m => m.if?.usedTags() ?? [])
?.filter(kv => kv.key === this.freeform.key)
?.map(kv => kv.value)
)
const freeformValues = tags[this.freeform.key].split(";") const freeformValues = tags[this.freeform.key].split(";")
const leftovers = freeformValues.filter((v) => !usedFreeformValues.has(v)) const leftovers = freeformValues.filter((v) => !usedFreeformValues.has(v.trim()))
for (const leftover of leftovers) { for (const leftover of leftovers) {
applicableMappings.push({ applicableMappings.push({
then: new TypedTranslation<object>( then: new TypedTranslation<object>(
@ -539,6 +542,23 @@ export default class TagRenderingConfig {
} }
if (this.freeform?.key === undefined || tags[this.freeform.key] !== undefined) { if (this.freeform?.key === undefined || tags[this.freeform.key] !== undefined) {
const postfix = this.freeform?.postfixDistinguished
if (postfix !== undefined) {
const allFreeforms = tags[this.freeform.key].split(";").map(s => s.trim())
for (const allFreeform of allFreeforms) {
if (allFreeform.endsWith(postfix)) {
const [v] = allFreeform.split("/")
// We found the needed postfix
return {
then: this.render.PartialSubs({ [this.freeform.key]: v.trim() }),
icon: this.renderIcon,
iconClass: this.renderIconClass
}
}
}
// needed postfix not found
return undefined
}
return { then: this.render, icon: this.renderIcon, iconClass: this.renderIconClass } return { then: this.render, icon: this.renderIcon, iconClass: this.renderIconClass }
} }
@ -643,6 +663,11 @@ export default class TagRenderingConfig {
* const tags = config.constructChangeSpecification("Tu-Fr 05:30-09:30", undefined, undefined, { }} * const tags = config.constructChangeSpecification("Tu-Fr 05:30-09:30", undefined, undefined, { }}
* tags // =>new And([ new Tag("opening_hours", "Tu-Fr 05:30-09:30")]) * tags // =>new And([ new Tag("opening_hours", "Tu-Fr 05:30-09:30")])
* *
* const config = new TagRenderingConfig({"id": "charge", render: "One tube costs {charge}", freeform: {key: "charge", postfixDistinguished: "bicycle_tube"]}, })
* const tags = config.constructChangeSpecification("€5", undefined, undefined, {vending: "books;bicycle_tubes" charge: "€42/book"})
* tags // =>new And([ new Tag("charge", "€5/bicycle_tube; €42/book")])
*
*
* @param freeformValue The freeform value which will be applied as 'freeform.key'. Ignored if 'freeform.key' is not set * @param freeformValue The freeform value which will be applied as 'freeform.key'. Ignored if 'freeform.key' is not set
* *
* @param singleSelectedMapping (Only used if multiAnswer == false): the single mapping to apply. Use (mappings.length) for the freeform * @param singleSelectedMapping (Only used if multiAnswer == false): the single mapping to apply. Use (mappings.length) for the freeform
@ -658,6 +683,7 @@ export default class TagRenderingConfig {
if (typeof freeformValue === "string") { if (typeof freeformValue === "string") {
freeformValue = freeformValue?.trim() freeformValue = freeformValue?.trim()
} }
const validator = Validators.get(<ValidatorType>this.freeform?.type) const validator = Validators.get(<ValidatorType>this.freeform?.type)
if (validator && freeformValue) { if (validator && freeformValue) {
freeformValue = validator.reformat(freeformValue, () => currentProperties["_country"]) freeformValue = validator.reformat(freeformValue, () => currentProperties["_country"])
@ -665,6 +691,18 @@ export default class TagRenderingConfig {
if (freeformValue === "") { if (freeformValue === "") {
freeformValue = undefined freeformValue = undefined
} }
if (this.freeform?.postfixDistinguished && freeformValue !== undefined) {
const allValues = currentProperties[this.freeform.key].split(";").map(s => s.trim())
const perPostfix: Record<string, string> = {}
for (const value of allValues) {
const [v, postfix] = value.split("/")
perPostfix[postfix.trim()] = v.trim()
}
perPostfix[this.freeform.postfixDistinguished] = freeformValue
const keys = Object.keys(perPostfix)
keys.sort()
freeformValue = keys.map(k => perPostfix[k] + "/" + k).join("; ")
}
if ( if (
freeformValue === undefined && freeformValue === undefined &&
singleSelectedMapping === undefined && singleSelectedMapping === undefined &&
@ -740,6 +778,7 @@ export default class TagRenderingConfig {
!someMappingIsShown || !someMappingIsShown ||
singleSelectedMapping === undefined) singleSelectedMapping === undefined)
if (useFreeform) { if (useFreeform) {
return new And([ return new And([
new Tag(this.freeform.key, freeformValue), new Tag(this.freeform.key, freeformValue),
...(this.freeform.addExtraTags ?? []) ...(this.freeform.addExtraTags ?? [])
@ -762,30 +801,23 @@ export default class TagRenderingConfig {
} }
} }
GenerateDocumentation(): BaseUIElement { GenerateDocumentation(): string {
let withRender: (BaseUIElement | string)[] = [] let withRender: string[] = []
if (this.freeform?.key !== undefined) { if (this.freeform?.key !== undefined) {
withRender = [ withRender = [
`This rendering asks information about the property `, `This rendering asks information about the property `,
Link.OsmWiki(this.freeform.key), Link.OsmWiki(this.freeform.key).AsMarkdown(),
new Combine([ "This is rendered with `" + this.render.txt + "`"
"This is rendered with ",
new FixedUiElement(this.render.txt).SetClass("code font-bold")
])
] ]
} }
let mappings: BaseUIElement = undefined let mappings: string = undefined
if (this.mappings !== undefined) { if (this.mappings !== undefined) {
mappings = new List( mappings = MarkdownUtils.list(
[].concat( this.mappings.flatMap((m) => {
...this.mappings.map((m) => { const msgs: (string)[] = [
const msgs: (string | BaseUIElement)[] = [ "*" + m.then.txt + "* corresponds with " +
new Combine([
new FixedUiElement(m.then.txt).SetClass("font-bold"),
" corresponds with ",
m.if.asHumanString(true, false, {}) m.if.asHumanString(true, false, {})
])
] ]
if (m.hideInAnswer === true) { if (m.hideInAnswer === true) {
msgs.push("_This option cannot be chosen as answer_") msgs.push("_This option cannot be chosen as answer_")
@ -799,43 +831,31 @@ export default class TagRenderingConfig {
return msgs return msgs
}) })
) )
)
} }
let condition: BaseUIElement = undefined let condition: string = undefined
if (this.condition !== undefined && !this.condition?.matchesProperties({})) { if (this.condition !== undefined && !this.condition?.matchesProperties({})) {
condition = new Combine([ const conditionAsLink = (<TagsFilter>this.condition.optimize()).asHumanString(true, false, {})
"This tagrendering is only visible in the popup if the following condition is met:", condition = "This tagrendering is only visible in the popup if the following condition is met: " + conditionAsLink
new FixedUiElement(
(<TagsFilter>this.condition.optimize()).asHumanString(true, false, {})
).SetClass("code")
])
} }
let labels: BaseUIElement = undefined let labels: string = undefined
if (this.labels?.length > 0) { if (this.labels?.length > 0) {
labels = new Combine([ labels = [
"This tagrendering has labels ", "This tagrendering has labels ",
...this.labels.map((label) => new FixedUiElement(label).SetClass("code")) ...this.labels.map((label) => "`" + label + "`")
]).SetClass("flex") ].join("\n")
} }
return new Combine([ return [
new Title(this.id, 3), "### this.id",
this.description, this.description,
this.question !== undefined this.question !== undefined ? ("The question is `" + this.question.txt + "`") : "_This tagrendering has no question and is thus read-only_",
? new Combine([ withRender.join("\n"),
"The question is ",
new FixedUiElement(this.question.txt).SetClass("font-bold bold")
])
: new FixedUiElement(
"This tagrendering has no question and is thus read-only"
).SetClass("italic"),
new Combine(withRender),
mappings, mappings,
condition, condition,
labels labels
]).SetClass("flex flex-col") ].join("\n")
} }
public usedTags(): TagsFilter[] { public usedTags(): TagsFilter[] {
@ -872,7 +892,7 @@ export class TagRenderingConfigUtils {
const extraMappings = tags const extraMappings = tags
.bindD(tags => { .bindD(tags => {
const country = tags._country const country = tags._country
if(country === undefined){ if (country === undefined) {
return undefined return undefined
} }
const center = GeoOperations.centerpointCoordinates(feature) const center = GeoOperations.centerpointCoordinates(feature)
@ -883,7 +903,10 @@ export class TagRenderingConfigUtils {
return config return config
} }
const clone: TagRenderingConfig = Object.create(config) const clone: TagRenderingConfig = Object.create(config)
const oldMappingsCloned = clone.mappings?.map(m => ({ ...m, priorityIf: m.priorityIf ?? TagUtils.Tag("id~*") })) ?? [] const oldMappingsCloned = clone.mappings?.map(m => ({
...m,
priorityIf: m.priorityIf ?? TagUtils.Tag("id~*")
})) ?? []
clone.mappings = [...oldMappingsCloned, ...extraMappings] clone.mappings = [...oldMappingsCloned, ...extraMappings]
return clone return clone
}) })

View file

@ -16,4 +16,7 @@ export default class MarkdownUtils {
return result return result
} }
static list(strings: string[]): string {
return strings.map(item => " - "+item).join("\n")
}
} }