forked from MapComplete/MapComplete
More work on searchable mappings
This commit is contained in:
parent
dd992a1e0d
commit
f9ce1e4db4
9 changed files with 398 additions and 227 deletions
|
@ -1,5 +1,5 @@
|
||||||
import {InputElement} from "./InputElement";
|
import {InputElement} from "./InputElement";
|
||||||
import {UIEventSource} from "../../Logic/UIEventSource";
|
import {Store, UIEventSource} from "../../Logic/UIEventSource";
|
||||||
|
|
||||||
|
|
||||||
export default class InputElementMap<T, X> extends InputElement<X> {
|
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,
|
isSame: (x0: X, x1: X) => boolean,
|
||||||
toX: (t: T) => X,
|
toX: (t: T) => X,
|
||||||
fromX: (x: X) => T,
|
fromX: (x: X) => T,
|
||||||
extraSources: UIEventSource<any>[] = []
|
extraSources: Store<any>[] = []
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.isSame = isSame;
|
this.isSame = isSame;
|
||||||
|
|
241
UI/Input/SearchableMappingsSelector.ts
Normal file
241
UI/Input/SearchableMappingsSelector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
|
||||||
import Title from "../Base/Title";
|
import Title from "../Base/Title";
|
||||||
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
|
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
|
||||||
import {GeoOperations} from "../../Logic/GeoOperations";
|
import {GeoOperations} from "../../Logic/GeoOperations";
|
||||||
|
import {SearchablePillsSelector} from "../Input/SearchableMappingsSelector";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the question element.
|
* Shows the question element.
|
||||||
|
@ -141,20 +142,26 @@ export default class TagRenderingQuestion extends Combine {
|
||||||
private static GenerateInputElement(
|
private static GenerateInputElement(
|
||||||
state: FeaturePipelineState,
|
state: FeaturePipelineState,
|
||||||
configuration: TagRenderingConfig,
|
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,
|
applicableUnit: Unit,
|
||||||
tagsSource: UIEventSource<any>,
|
tagsSource: UIEventSource<any>,
|
||||||
feedback: UIEventSource<Translation>
|
feedback: UIEventSource<Translation>
|
||||||
): ReadonlyInputElement<TagsFilter> {
|
): 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
|
const hasImages = applicableMappings.findIndex(mapping => mapping.icon !== undefined) >= 0
|
||||||
let inputEls: InputElement<TagsFilter>[];
|
let inputEls: InputElement<TagsFilter>[];
|
||||||
|
|
||||||
const ifNotsPresent = applicableMappings.some(mapping => mapping.ifnot !== undefined)
|
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[] {
|
function allIfNotsExcept(excludeIndex: number): TagsFilter[] {
|
||||||
if (configuration.mappings === undefined || configuration.mappings.length === 0) {
|
if (configuration.mappings === undefined || configuration.mappings.length === 0) {
|
||||||
return undefined
|
return undefined
|
||||||
|
@ -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(
|
private static GenerateMultiAnswer(
|
||||||
configuration: TagRenderingConfig,
|
configuration: TagRenderingConfig,
|
||||||
|
@ -337,7 +402,7 @@ export default class TagRenderingQuestion extends Combine {
|
||||||
then: Translation,
|
then: Translation,
|
||||||
addExtraTags: Tag[],
|
addExtraTags: Tag[],
|
||||||
icon?: string,
|
icon?: string,
|
||||||
iconClass?: string
|
iconClass?: "small" | "medium" | "large" | "small-height"
|
||||||
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
|
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
|
||||||
|
|
||||||
let tagging: TagsFilter = mapping.if;
|
let tagging: TagsFilter = mapping.if;
|
||||||
|
@ -358,13 +423,13 @@ export default class TagRenderingQuestion extends Combine {
|
||||||
private static GenerateMappingContent(mapping: {
|
private static GenerateMappingContent(mapping: {
|
||||||
then: Translation,
|
then: Translation,
|
||||||
icon?: string,
|
icon?: string,
|
||||||
iconClass?: string
|
iconClass?: "small" | "medium" | "large" | "small-height"
|
||||||
}, tagsSource: UIEventSource<any>, state: FeaturePipelineState): BaseUIElement {
|
}, tagsSource: UIEventSource<any>, state: FeaturePipelineState): BaseUIElement {
|
||||||
const text = new SubstitutedTranslation(mapping.then, tagsSource, state)
|
const text = new SubstitutedTranslation(mapping.then, tagsSource, state)
|
||||||
if (mapping.icon === undefined) {
|
if (mapping.icon === undefined) {
|
||||||
return text;
|
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>)
|
private static GenerateFreeform(state: FeaturePipelineState, configuration: TagRenderingConfig, applicableUnit: Unit, tags: UIEventSource<any>, feedback: UIEventSource<Translation>)
|
||||||
|
|
|
@ -105,6 +105,13 @@
|
||||||
"question": {
|
"question": {
|
||||||
"en": "What kind of shop is this?",
|
"en": "What kind of shop is this?",
|
||||||
"nl": "Wat voor soort winkel is dit?"
|
"nl": "Wat voor soort winkel is dit?"
|
||||||
|
},
|
||||||
|
"render": {
|
||||||
|
"en":"This is a {shop}"
|
||||||
|
},
|
||||||
|
"freeform": {
|
||||||
|
"key": "shop",
|
||||||
|
"addExtraTags": ["fixme=freeform shop key used, to be reviewed"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -858,10 +858,6 @@ video {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mr-4 {
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-3 {
|
.ml-3 {
|
||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
}
|
}
|
||||||
|
@ -890,6 +886,10 @@ video {
|
||||||
margin-bottom: 6rem;
|
margin-bottom: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mr-4 {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mt-2 {
|
.mt-2 {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
@ -950,6 +950,10 @@ video {
|
||||||
margin-top: -3rem;
|
margin-top: -3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mr-1 {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-0 {
|
.mb-0 {
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
@ -1126,8 +1130,8 @@ video {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-1\/2 {
|
.w-1 {
|
||||||
width: 50%;
|
width: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-24 {
|
.w-24 {
|
||||||
|
@ -1167,6 +1171,10 @@ video {
|
||||||
width: min-content;
|
width: min-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-1\/2 {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.w-max {
|
.w-max {
|
||||||
width: -webkit-max-content;
|
width: -webkit-max-content;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
@ -1400,10 +1408,6 @@ video {
|
||||||
border-bottom-left-radius: 0.25rem;
|
border-bottom-left-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-4 {
|
|
||||||
border-width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
|
@ -1412,6 +1416,10 @@ video {
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-4 {
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.border-l-4 {
|
.border-l-4 {
|
||||||
border-left-width: 4px;
|
border-left-width: 4px;
|
||||||
}
|
}
|
||||||
|
@ -1420,16 +1428,16 @@ video {
|
||||||
border-bottom-width: 1px;
|
border-bottom-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-black {
|
|
||||||
--tw-border-opacity: 1;
|
|
||||||
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-gray-500 {
|
.border-gray-500 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgba(107, 114, 128, var(--tw-border-opacity));
|
border-color: rgba(107, 114, 128, var(--tw-border-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-black {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgba(0, 0, 0, var(--tw-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.border-gray-400 {
|
.border-gray-400 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgba(156, 163, 175, var(--tw-border-opacity));
|
border-color: rgba(156, 163, 175, var(--tw-border-opacity));
|
||||||
|
@ -1508,14 +1516,14 @@ video {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.p-1 {
|
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.p-1 {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.p-2 {
|
.p-2 {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
@ -1528,16 +1536,16 @@ video {
|
||||||
padding: 0.125rem;
|
padding: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.px-4 {
|
|
||||||
padding-left: 1rem;
|
|
||||||
padding-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.px-0 {
|
.px-0 {
|
||||||
padding-left: 0px;
|
padding-left: 0px;
|
||||||
padding-right: 0px;
|
padding-right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.px-4 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pr-2 {
|
.pr-2 {
|
||||||
padding-right: 0.5rem;
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
@ -2455,6 +2463,13 @@ input {
|
||||||
/* Additional class on the first layer filter */
|
/* Additional class on the first layer filter */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapping-icon-small-height {
|
||||||
|
/* A mapping icon type */
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
.mapping-icon-small {
|
.mapping-icon-small {
|
||||||
/* A mapping icon type */
|
/* A mapping icon type */
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
|
|
|
@ -620,6 +620,12 @@ input {
|
||||||
/* Additional class on the first layer filter */
|
/* Additional class on the first layer filter */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapping-icon-small-height {
|
||||||
|
/* A mapping icon type */
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
.mapping-icon-small {
|
.mapping-icon-small {
|
||||||
/* A mapping icon type */
|
/* A mapping icon type */
|
||||||
|
|
|
@ -160,6 +160,7 @@
|
||||||
},
|
},
|
||||||
"nameInlineQuestion": "The name of this {category} is $$$",
|
"nameInlineQuestion": "The name of this {category} is $$$",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
|
"noMatchingMapping": "No entries mapped your search…",
|
||||||
"noNameCategory": "{category} without a name",
|
"noNameCategory": "{category} without a name",
|
||||||
"noTagsSelected": "No tags selected",
|
"noTagsSelected": "No tags selected",
|
||||||
"notValid": "Select a valid value to continue",
|
"notValid": "Select a valid value to continue",
|
||||||
|
|
184
test.ts
184
test.ts
|
@ -2,187 +2,18 @@ import * as shops from "./assets/generated/layers/shops.json"
|
||||||
import Combine from "./UI/Base/Combine";
|
import Combine from "./UI/Base/Combine";
|
||||||
import Img from "./UI/Base/Img";
|
import Img from "./UI/Base/Img";
|
||||||
import BaseUIElement from "./UI/BaseUIElement";
|
import BaseUIElement from "./UI/BaseUIElement";
|
||||||
import Svg from "./Svg";
|
|
||||||
import {TextField} from "./UI/Input/TextField";
|
|
||||||
import {Store, UIEventSource} from "./Logic/UIEventSource";
|
|
||||||
import {VariableUiElement} from "./UI/Base/VariableUIElement";
|
import {VariableUiElement} from "./UI/Base/VariableUIElement";
|
||||||
import Locale from "./UI/i18n/Locale";
|
|
||||||
import LanguagePicker from "./UI/LanguagePicker";
|
import LanguagePicker from "./UI/LanguagePicker";
|
||||||
import {InputElement} from "./UI/Input/InputElement";
|
|
||||||
import {UIElement} from "./UI/UIElement";
|
|
||||||
import Translations from "./UI/i18n/Translations";
|
|
||||||
import TagRenderingConfig, {Mapping} from "./Models/ThemeConfig/TagRenderingConfig";
|
import TagRenderingConfig, {Mapping} from "./Models/ThemeConfig/TagRenderingConfig";
|
||||||
import {MappingConfigJson} from "./Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson";
|
import {MappingConfigJson} from "./Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson";
|
||||||
import {FixedUiElement} from "./UI/Base/FixedUiElement";
|
import {FixedUiElement} from "./UI/Base/FixedUiElement";
|
||||||
import {TagsFilter} from "./Logic/Tags/TagsFilter";
|
import {TagsFilter} from "./Logic/Tags/TagsFilter";
|
||||||
|
import {SearchablePillsSelector} from "./UI/Input/SearchableMappingsSelector";
|
||||||
|
import {UIEventSource} from "./Logic/UIEventSource";
|
||||||
|
|
||||||
const mappingsRaw: MappingConfigJson[] = <any>shops.tagRenderings.find(tr => tr.id == "shop_types").mappings
|
const mappingsRaw: MappingConfigJson[] = <any>shops.tagRenderings.find(tr => tr.id == "shop_types").mappings
|
||||||
const mappings = mappingsRaw.map((m, i) => TagRenderingConfig.ExtractMapping(m, i, "test", "test"))
|
const mappings = mappingsRaw.map((m, i) => TagRenderingConfig.ExtractMapping(m, i, "test", "test"))
|
||||||
|
|
||||||
|
|
||||||
export class SelfHidingToggle extends UIElement implements InputElement<boolean> {
|
|
||||||
private readonly _shown: BaseUIElement;
|
|
||||||
private readonly _searchTerms: Record<string, string[]>;
|
|
||||||
private readonly _search: Store<string>;
|
|
||||||
|
|
||||||
private readonly _selected: UIEventSource<boolean>
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
shown: string | BaseUIElement,
|
|
||||||
mainTerm: Record<string, string>,
|
|
||||||
search: Store<string>,
|
|
||||||
searchTerms?: Record<string, string[]>,
|
|
||||||
selected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
this._shown = Translations.W(shown);
|
|
||||||
this._search = search;
|
|
||||||
this._searchTerms = {};
|
|
||||||
for (const lng in searchTerms ?? []) {
|
|
||||||
if (lng === "_context") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
this._searchTerms[lng] = searchTerms[lng].map(t => t.trim().toLowerCase())
|
|
||||||
}
|
|
||||||
for (const lng in mainTerm) {
|
|
||||||
if (lng === "_context") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
this._searchTerms[lng] = [mainTerm[lng]].concat(this._searchTerms[lng] ?? [])
|
|
||||||
}
|
|
||||||
this._selected = selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
||||||
const search = this._search;
|
|
||||||
const terms = this._searchTerms;
|
|
||||||
const applySearch = () => {
|
|
||||||
const s = search.data?.trim()?.toLowerCase()
|
|
||||||
if (s === undefined || s.length === 0 || selected.data) {
|
|
||||||
el.RemoveClass("hidden")
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terms[Locale.language.data].some(t => t.toLowerCase().indexOf(s) >= 0)) {
|
|
||||||
el.RemoveClass("hidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
el.SetClass("hidden")
|
|
||||||
}
|
|
||||||
search.addCallbackAndRun(_ => {
|
|
||||||
applySearch()
|
|
||||||
})
|
|
||||||
Locale.language.addCallback(_ => {
|
|
||||||
applySearch()
|
|
||||||
})
|
|
||||||
|
|
||||||
selected.addCallbackAndRun(selected => {
|
|
||||||
if (selected) {
|
|
||||||
el.SetClass("border-4")
|
|
||||||
el.RemoveClass("border")
|
|
||||||
el.SetStyle("margin: calc( 0.25rem )")
|
|
||||||
} else {
|
|
||||||
el.SetStyle("margin: calc( 0.25rem + 3px )")
|
|
||||||
el.SetClass("border")
|
|
||||||
el.RemoveClass("border-4")
|
|
||||||
}
|
|
||||||
applySearch()
|
|
||||||
})
|
|
||||||
|
|
||||||
el.onClick(() => selected.setData(!selected.data))
|
|
||||||
|
|
||||||
return el.SetClass("border border-black rounded-full p-1 px-4")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SearchablePresets<T> extends Combine implements InputElement<T[]> {
|
|
||||||
private selectedElements: UIEventSource<T[]>;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
values: { show: BaseUIElement, value: T, mainTerm: Record<string, string>, searchTerms?: Record<string, string[]> }[],
|
|
||||||
mode: "select-one" | "select-many",
|
|
||||||
selectedElements: UIEventSource<T[]> = new UIEventSource<T[]>([])) {
|
|
||||||
|
|
||||||
const search = new TextField({})
|
|
||||||
|
|
||||||
const searchBar = new Combine([Svg.search_svg().SetClass("w-8"), search.SetClass("mr-4 w-full")])
|
|
||||||
.SetClass("flex rounded-full border-2 border-black items-center my-2 w-1/2")
|
|
||||||
|
|
||||||
const searchValue = search.GetValue().map(s => s?.trim()?.toLowerCase())
|
|
||||||
|
|
||||||
|
|
||||||
values = 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...v,
|
|
||||||
show: new SelfHidingToggle(v.show, v.mainTerm, searchValue, v.searchTerms, vIsSelected)
|
|
||||||
};
|
|
||||||
})
|
|
||||||
|
|
||||||
super([
|
|
||||||
searchBar,
|
|
||||||
new VariableUiElement(Locale.language.map(lng => {
|
|
||||||
values.sort((a, b) => a.mainTerm[lng] < b.mainTerm[lng] ? -1 : 1)
|
|
||||||
return new Combine(values.map(e => e.show))
|
|
||||||
.SetClass("flex flex-wrap w-full")
|
|
||||||
}))
|
|
||||||
|
|
||||||
])
|
|
||||||
this.selectedElements = selectedElements;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public GetValue(): UIEventSource<T[]> {
|
|
||||||
return this.selectedElements;
|
|
||||||
}
|
|
||||||
|
|
||||||
IsValid(t: T[]): boolean {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function fromMapping(m: Mapping): { show: BaseUIElement, value: TagsFilter, mainTerm: Record<string, string>, searchTerms?: Record<string, string[]> } {
|
function fromMapping(m: Mapping): { show: BaseUIElement, value: TagsFilter, mainTerm: Record<string, string>, searchTerms?: Record<string, string[]> } {
|
||||||
const el: BaseUIElement = m.then
|
const el: BaseUIElement = m.then
|
||||||
let icon: BaseUIElement
|
let icon: BaseUIElement
|
||||||
|
@ -199,10 +30,15 @@ function fromMapping(m: Mapping): { show: BaseUIElement, value: TagsFilter, main
|
||||||
return {show, mainTerm: m.then.translations, searchTerms: m.searchTerms, value: m.if};
|
return {show, mainTerm: m.then.translations, searchTerms: m.searchTerms, value: m.if};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
const search = new UIEventSource("")
|
||||||
const sp = new SearchablePresets(
|
const sp = new SearchablePillsSelector(
|
||||||
mappings.map(m => fromMapping(m)),
|
mappings.map(m => fromMapping(m)),
|
||||||
"select-one"
|
{
|
||||||
|
noMatchFound: new VariableUiElement(search.map(s => "Mark this a `"+s+"`")),
|
||||||
|
onNoSearch: new FixedUiElement("Search in "+mappingsRaw.length+" categories"),
|
||||||
|
selectIfSingle: true,
|
||||||
|
searchValue: search
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
sp.AttachTo("maindiv")
|
sp.AttachTo("maindiv")
|
||||||
|
|
Loading…
Reference in a new issue