More work on searchable mappings

This commit is contained in:
Pieter Vander Vennet 2022-07-10 03:58:07 +02:00
parent dd992a1e0d
commit f9ce1e4db4
9 changed files with 398 additions and 227 deletions

View file

@ -1,5 +1,5 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
export default class InputElementMap<T, X> extends InputElement<X> {
@ -13,7 +13,7 @@ export default class InputElementMap<T, X> extends InputElement<X> {
isSame: (x0: X, x1: X) => boolean,
toX: (t: T) => X,
fromX: (x: X) => T,
extraSources: UIEventSource<any>[] = []
extraSources: Store<any>[] = []
) {
super();
this.isSame = isSame;

View file

@ -0,0 +1,241 @@
import {UIElement} from "../UIElement";
import {InputElement} from "./InputElement";
import BaseUIElement from "../BaseUIElement";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import Locale from "../i18n/Locale";
import Combine from "../Base/Combine";
import {TextField} from "./TextField";
import Svg from "../../Svg";
import {VariableUiElement} from "../Base/VariableUIElement";
/**
* A single 'pill' which can hide itself if the search criteria is not met
*/
class SelfHidingToggle extends UIElement implements InputElement<boolean> {
private readonly _shown: BaseUIElement;
public readonly _selected: UIEventSource<boolean>
public readonly isShown: Store<boolean> = new UIEventSource<boolean>(true);
public readonly forceSelected: UIEventSource<boolean>
public constructor(
shown: string | BaseUIElement,
mainTerm: Record<string, string>,
search: Store<string>,
options?: {
searchTerms?: Record<string, string[]>,
selected?: UIEventSource<boolean>,
forceSelected?: UIEventSource<boolean>
}
) {
super();
this._shown = Translations.W(shown);
const searchTerms: Record<string, string[]> = {};
for (const lng in options?.searchTerms ?? []) {
if (lng === "_context") {
continue
}
searchTerms[lng] = options?.searchTerms[lng]?.map(SelfHidingToggle.clean)
}
for (const lng in mainTerm) {
if (lng === "_context") {
continue
}
const main = SelfHidingToggle.clean( mainTerm[lng])
searchTerms[lng] = [main].concat(searchTerms[lng] ?? [])
}
const selected = this._selected = options?.selected ?? new UIEventSource<boolean>(false);
const forceSelected = this.forceSelected = options?.forceSelected ?? new UIEventSource<boolean>(false)
this.isShown = search.map(s => {
if (s === undefined || s.length === 0) {
return true;
}
if (selected.data && !forceSelected.data) {
return true
}
s = s?.trim()?.toLowerCase()
return searchTerms[Locale.language.data].some(t => t.indexOf(s) >= 0);
}, [selected, Locale.language])
const self = this;
this.isShown.addCallbackAndRun(shown => {
if (shown) {
self.RemoveClass("hidden")
} else {
self.SetClass("hidden")
}
})
}
private static clean(s: string) : string{
return s?.trim()?.toLowerCase()?.replace(/[-]/, "")
}
GetValue(): UIEventSource<boolean> {
return this._selected
}
IsValid(t: boolean): boolean {
return true;
}
protected InnerRender(): string | BaseUIElement {
let el: BaseUIElement = this._shown;
const selected = this._selected;
selected.addCallbackAndRun(selected => {
if (selected) {
el.SetClass("border-4")
el.RemoveClass("border")
el.SetStyle("margin: 0")
} else {
el.SetStyle("margin: 3px")
el.SetClass("border")
el.RemoveClass("border-4")
}
})
el.onClick(() => selected.setData(!selected.data))
return el.SetClass("border border-black rounded-full p-1 px-4")
}
}
/**
* The searchable mappings selector is a selector which shows various pills from which one (or more) options can be chosen.
* A searchfield can be used to filter the values
*/
export class SearchablePillsSelector<T> extends Combine implements InputElement<T[]> {
private selectedElements: UIEventSource<T[]>;
public readonly someMatchFound: Store<boolean>;
constructor(
values: { show: BaseUIElement, value: T, mainTerm: Record<string, string>, searchTerms?: Record<string, string[]> }[],
options?: {
mode?: "select-one" | "select-many",
selectedElements?: UIEventSource<T[]>,
searchValue?: UIEventSource<string>,
onNoMatches?: BaseUIElement,
onNoSearchMade?: BaseUIElement,
selectIfSingle?: false | boolean,
searchAreaClass?: string
}) {
const search = new TextField({value: options?.searchValue})
const searchBar = new Combine([Svg.search_svg().SetClass("w-8 normal-background"), search.SetClass("w-full")])
.SetClass("flex items-center border-2 border-black m-2")
const searchValue = search.GetValue().map(s => s?.trim()?.toLowerCase())
const selectedElements = options?.selectedElements ?? new UIEventSource<T[]>([]);
const mode = options?.mode ?? "select-one";
const onEmpty = options?.onNoMatches ?? Translations.t.general.noMatchingMapping
const mappedValues: { show: SelfHidingToggle, mainTerm: Record<string, string>, value: T }[] = values.map(v => {
const vIsSelected = new UIEventSource(false);
selectedElements.addCallbackAndRunD(selectedElements => {
vIsSelected.setData(selectedElements.some(t => t === v.value))
})
vIsSelected.addCallback(selected => {
if (selected) {
if (mode === "select-one") {
selectedElements.setData([v.value])
} else if (!selectedElements.data.some(t => t === v.value)) {
selectedElements.data.push(v.value);
selectedElements.ping()
}
} else {
for (let i = 0; i < selectedElements.data.length; i++) {
const t = selectedElements.data[i]
if (t == v.value) {
selectedElements.data.splice(i, 1)
selectedElements.ping()
break;
}
}
}
})
const toggle = new SelfHidingToggle(v.show, v.mainTerm, searchValue, {
searchTerms: v.searchTerms,
selected: vIsSelected
})
return {
...v,
show: toggle
};
})
let somethingShown: Store<boolean>
if (options.selectIfSingle) {
let forcedSelection : { value: T, show: SelfHidingToggle }= undefined
somethingShown = searchValue.map(_ => {
let totalShown = 0;
let lastShownValue: { value: T, show: SelfHidingToggle }
for (const mv of mappedValues) {
const valueIsShown = mv.show.isShown.data
if (valueIsShown) {
totalShown++;
lastShownValue = mv
}
}
if (totalShown == 1) {
if (this.selectedElements.data.indexOf(lastShownValue.value) < 0) {
this.selectedElements.setData([lastShownValue.value])
lastShownValue.show.forceSelected.setData(true)
forcedSelection = lastShownValue
}
} else if (forcedSelection != undefined) {
this.selectedElements.setData([])
forcedSelection.show.forceSelected.setData(false)
forcedSelection = undefined;
}
return totalShown > 0
}, mappedValues.map(mv => mv.show.GetValue()))
} else {
somethingShown = searchValue.map(_ => mappedValues.some(mv => mv.show.isShown.data), mappedValues.map(mv => mv.show.GetValue()))
}
super([
searchBar,
new VariableUiElement(Locale.language.map(lng => {
if (options?.onNoSearchMade !== undefined && (searchValue.data === undefined || searchValue.data.length === 0)) {
return options?.onNoSearchMade
}
if (!somethingShown.data) {
return onEmpty
}
mappedValues.sort((a, b) => a.mainTerm[lng] < b.mainTerm[lng] ? -1 : 1)
return new Combine(mappedValues.map(e => e.show))
.SetClass("flex flex-wrap w-full")
.SetClass(options?.searchAreaClass ?? "")
}, [somethingShown, searchValue]))
])
this.selectedElements = selectedElements;
this.someMatchFound = somethingShown;
}
public GetValue(): UIEventSource<T[]> {
return this.selectedElements;
}
IsValid(t: T[]): boolean {
return true;
}
}

View file

@ -31,6 +31,7 @@ import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
import Title from "../Base/Title";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {GeoOperations} from "../../Logic/GeoOperations";
import {SearchablePillsSelector} from "../Input/SearchableMappingsSelector";
/**
* Shows the question element.
@ -141,19 +142,25 @@ export default class TagRenderingQuestion extends Combine {
private static GenerateInputElement(
state: FeaturePipelineState,
configuration: TagRenderingConfig,
applicableMappings: { if: TagsFilter, then: TypedTranslation<object>, icon?: string, ifnot?: TagsFilter, addExtraTags: Tag[] }[],
applicableMappings: { if: TagsFilter, then: TypedTranslation<object>, icon?: string, ifnot?: TagsFilter, addExtraTags: Tag[], searchTerms?: Record<string, string[]> }[],
applicableUnit: Unit,
tagsSource: UIEventSource<any>,
feedback: UIEventSource<Translation>
): ReadonlyInputElement<TagsFilter> {
// 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, feedback);
const hasImages = applicableMappings.findIndex(mapping => mapping.icon !== undefined) >= 0
let inputEls: InputElement<TagsFilter>[];
const ifNotsPresent = applicableMappings.some(mapping => mapping.ifnot !== undefined)
if(applicableMappings.length > 8 && !ifNotsPresent && (configuration.freeform?.type === undefined || configuration.freeform?.type === "string")){
return TagRenderingQuestion.GenerateSearchableSelector(state, configuration, applicableMappings, tagsSource)
}
// 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, feedback);
function allIfNotsExcept(excludeIndex: number): TagsFilter[] {
if (configuration.mappings === undefined || configuration.mappings.length === 0) {
@ -221,6 +228,64 @@ export default class TagRenderingQuestion extends Combine {
}
private static GenerateSearchableSelector(
state: FeaturePipelineState,
configuration: TagRenderingConfig,
applicableMappings: { if: TagsFilter; then: TypedTranslation<object>; icon?: string; iconClass?: string, addExtraTags: Tag[], searchTerms?: Record<string, string[]> }[], tagsSource: UIEventSource<any>): InputElement<TagsFilter>{
const values : { show: BaseUIElement, value: TagsFilter, mainTerm: Record<string, string>, searchTerms?: Record<string, string[]> }[] = []
for (const mapping of applicableMappings) {
const tr = mapping.then.Subs(tagsSource.data)
const patchedMapping = <{iconClass: "small-height", then: TypedTranslation<object>}> {...mapping, iconClass: "small-height"}
const fancy = TagRenderingQuestion.GenerateMappingContent(patchedMapping, tagsSource, state).SetClass("normal-background")
values.push({
show: fancy,
value: mapping.if,
mainTerm: tr.translations,
searchTerms: mapping.searchTerms
})
}
const searchValue: UIEventSource<string> = new UIEventSource<string>(undefined)
const ff = configuration.freeform
let onEmpty : BaseUIElement = undefined
if(ff !== undefined){
onEmpty = new VariableUiElement(searchValue.map(search => configuration.render.Subs({[ff.key] : search})))
}
const classes = "h-32 overflow-scroll"
const presetSearch = new SearchablePillsSelector<TagsFilter>(values,{
selectIfSingle: true,
mode: configuration.multiAnswer ? "select-many" : "select-one",
searchValue,
onNoMatches: onEmpty?.SetClass(classes).SetClass("flex justify-center items-center"),
searchAreaClass:classes
})
return new InputElementMap(presetSearch,
(x0, x1) => false,
arr => {
console.log("Arr is ", arr)
if(arr[0] !== undefined){
return new And(arr)
}
if(ff !== undefined && searchValue.data?.length > 0 && !presetSearch.someMatchFound.data){
const t = new Tag(ff.key, searchValue.data)
if(ff.addExtraTags){
return new And([t, ...ff.addExtraTags])
}
return t;
}
return undefined;
},
tf => {
if(tf["and"] !== undefined){
return tf["and"];
}
return [tf];
},
[searchValue, presetSearch.someMatchFound]
);
}
private static GenerateMultiAnswer(
configuration: TagRenderingConfig,
@ -337,7 +402,7 @@ export default class TagRenderingQuestion extends Combine {
then: Translation,
addExtraTags: Tag[],
icon?: string,
iconClass?: string
iconClass?: "small" | "medium" | "large" | "small-height"
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
let tagging: TagsFilter = mapping.if;
@ -358,13 +423,13 @@ export default class TagRenderingQuestion extends Combine {
private static GenerateMappingContent(mapping: {
then: Translation,
icon?: string,
iconClass?: string
iconClass?: "small" | "medium" | "large" | "small-height"
}, tagsSource: UIEventSource<any>, 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("mapping-icon-"+(mapping.iconClass ?? "small")), text]).SetClass("flex")
return new Combine([new Img(mapping.icon).SetClass("mr-1 mapping-icon-"+(mapping.iconClass ?? "small")), text]).SetClass("flex")
}
private static GenerateFreeform(state: FeaturePipelineState, configuration: TagRenderingConfig, applicableUnit: Unit, tags: UIEventSource<any>, feedback: UIEventSource<Translation>)