Way to much fixes and improvements

This commit is contained in:
Pieter Vander Vennet 2020-09-02 11:37:34 +02:00
parent e68d9d99a5
commit 5ed0bb431c
41 changed files with 1244 additions and 402 deletions

View file

@ -2,14 +2,17 @@ import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
export default class Combine extends UIElement {
private uiElements: (string | UIElement)[];
private className: string = undefined;
private clas: string = undefined;
private readonly uiElements: (string | UIElement)[];
private readonly className: string = undefined;
constructor(uiElements: (string | UIElement)[], className: string = undefined) {
super(undefined);
this.dumbMode = false;
this.className = className;
this.uiElements = uiElements;
if (className) {
console.error("Deprecated used of className")
}
}
InnerRender(): string {

20
UI/Base/PageSplit.ts Normal file
View file

@ -0,0 +1,20 @@
import {UIElement} from "../UIElement";
export default class PageSplit extends UIElement{
private _left: UIElement;
private _right: UIElement;
private _leftPercentage: number;
constructor(left: UIElement, right:UIElement,
leftPercentage: number = 50) {
super();
this._left = left;
this._right = right;
this._leftPercentage = leftPercentage;
}
InnerRender(): string {
return `<span class="page-split" style="height: min-content"><span style="width:${this._leftPercentage}%">${this._left.Render()}</span><span style="width:${100-this._leftPercentage}">${this._right.Render()}</span></span>`;
}
}

View file

@ -4,9 +4,9 @@ import Combine from "./Combine";
export class SubtleButton extends UIElement{
private imageUrl: string;
private message: UIElement;
private linkTo: { url: string, newTab?: boolean } = undefined;
private readonly imageUrl: string;
private readonly message: UIElement;
private readonly linkTo: { url: string, newTab?: boolean } = undefined;
constructor(imageUrl: string, message: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined) {
super(undefined);
@ -18,7 +18,7 @@ export class SubtleButton extends UIElement{
InnerRender(): string {
if(this.message.IsEmpty()){
if(this.message !== null && this.message.IsEmpty()){
return "";
}
@ -26,7 +26,7 @@ export class SubtleButton extends UIElement{
return new Combine([
`<a class="subtle-button" href="${this.linkTo.url}" ${this.linkTo.newTab ? 'target="_blank"' : ""}>`,
this.imageUrl !== undefined ? `<img src='${this.imageUrl}'>` : "",
this.message,
this.message ?? "",
'</a>'
]).Render();
}
@ -34,7 +34,7 @@ export class SubtleButton extends UIElement{
return new Combine([
'<span class="subtle-button">',
this.imageUrl !== undefined ? `<img src='${this.imageUrl}'>` : "",
this.message,
this.message ?? "",
'</span>'
]).Render();
}

View file

@ -7,8 +7,8 @@ export class TabbedComponent extends UIElement {
private headers: UIElement[] = [];
private content: UIElement[] = [];
constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab : UIEventSource<number> = new UIEventSource<number>(0)) {
super(openedTab);
constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab: (UIEventSource<number> | number) = 0) {
super(typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0)));
const self = this;
for (let i = 0; i < elements.length; i++) {
let element = elements[i];

View file

@ -3,34 +3,30 @@ import {TabbedComponent} from "../Base/TabbedComponent";
import {SubtleButton} from "../Base/SubtleButton";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson";
import LayerPanel from "./LayerPanel";
import SingleSetting from "./SingleSetting";
import Combine from "../Base/Combine";
import {GenerateEmpty} from "./GenerateEmpty";
import PageSplit from "../Base/PageSplit";
import {VariableUiElement} from "../Base/VariableUIElement";
import HelpText from "../../Customizations/HelpText";
import {MultiTagInput} from "../Input/MultiTagInput";
import {FromJSON} from "../../Customizations/JSON/FromJSON";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
import {FixedUiElement} from "../Base/FixedUiElement";
import TagRenderingPanel from "./TagRenderingPanel";
export default class AllLayersPanel extends UIElement {
private panel: UIElement;
private _config: UIEventSource<LayoutConfigJson>;
private _currentlySelected: UIEventSource<SingleSetting<any>>;
private languages: UIEventSource<string[]>;
private readonly _config: UIEventSource<LayoutConfigJson>;
private readonly languages: UIEventSource<string[]>;
private static createEmptyLayer(): LayerConfigJson {
return {
id: undefined,
name: undefined,
minzoom: 0,
overpassTags: undefined,
title: undefined,
description: {}
}
}
constructor(config: UIEventSource<LayoutConfigJson>, currentlySelected: UIEventSource<SingleSetting<any>>,
constructor(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<any>) {
super(undefined);
this._config = config;
this._currentlySelected = currentlySelected;
this.languages = languages;
this.createPanels();
@ -46,23 +42,85 @@ export default class AllLayersPanel extends UIElement {
const layers = this._config.data.layers;
for (let i = 0; i < layers.length; i++) {
const currentlySelected = new UIEventSource<(SingleSetting<any>)>(undefined);
const layer = new LayerPanel(this._config, this.languages, i, currentlySelected);
const helpText = new HelpText(currentlySelected);
const previewTagInput = new MultiTagInput();
previewTagInput.GetValue().setData(["id=123456"]);
const previewTagValue = previewTagInput.GetValue().map(tags => {
const properties = {};
for (const str of tags) {
const tag = FromJSON.SimpleTag(str);
if (tag !== undefined) {
properties[tag.key] = tag.value;
}
}
return properties;
});
const preview = new VariableUiElement(layer.selectedTagRendering.map(
(tagRenderingPanel: TagRenderingPanel) => {
if (tagRenderingPanel === undefined) {
return "No tag rendering selected at the moment";
}
let es = tagRenderingPanel.GetValue();
let tagRenderingConfig: TagRenderingConfigJson = es.data;
let rendering: UIElement;
try {
rendering = FromJSON.TagRendering(tagRenderingConfig)
.construct({tags: previewTagValue})
} catch (e) {
console.error("User defined tag rendering incorrect:", e);
rendering = new FixedUiElement(e).SetClass("alert");
}
return new Combine([
"<h3>",
tagRenderingPanel.options.title ?? "Extra tag rendering",
"</h3>",
tagRenderingPanel.options.description ?? "This tag rendering will appear in the popup",
"<br/>",
rendering]).Render();
},
[this._config]
)).ListenTo(layer.selectedTagRendering);
tabs.push({
header: "<img src='./assets/bug.svg'>",
content: new LayerPanel(this._config, this.languages, i, this._currentlySelected)
content:
new PageSplit(
layer.SetClass("scrollable"),
new Combine([
helpText,
"</br>",
"<h2>Testing tags</h2>",
previewTagInput,
"<h2>Tag Rendering preview</h2>",
preview
]), 60
)
});
}
tabs.push({
header: "<img src='./assets/add.svg'>",
content: new SubtleButton(
"./assets/add.svg",
"Add a new layer"
).onClick(() => {
self._config.data.layers.push(AllLayersPanel.createEmptyLayer())
self._config.ping();
})
content: new Combine([
"<h2>Layer editor</h2>",
"In this tab page, you can add and edit the layers of the theme. Click the layers above or add a new layer to get started.",
new SubtleButton(
"./assets/add.svg",
"Add a new layer"
).onClick(() => {
self._config.data.layers.push(GenerateEmpty.createEmptyLayer())
self._config.ping();
})])
})
this.panel = new TabbedComponent(tabs, new UIEventSource<number>(Math.max(0, layers.length-1)));
this.panel = new TabbedComponent(tabs, new UIEventSource<number>(Math.max(0, layers.length - 1)));
this.Update();
}

View file

@ -0,0 +1,67 @@
import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
export class GenerateEmpty {
public static createEmptyLayer(): LayerConfigJson {
return {
id: undefined,
name: undefined,
minzoom: 0,
overpassTags: {and: [""]},
title: undefined,
description: {},
}
}
public static createEmptyLayout(): LayoutConfigJson {
return {
id: "",
title: {},
description: {},
language: [],
maintainer: "",
icon: "./assets/bug.svg",
version: "0",
startLat: 0,
startLon: 0,
startZoom: 1,
socialImage: "",
layers: []
}
}
public static createTestLayout(): LayoutConfigJson {
return {
id: "test",
title: {"en": "Test layout"},
description: {"en": "A layout for testing"},
language: ["en"],
maintainer: "Pieter Vander Vennet",
icon: "./assets/bug.svg",
version: "0",
startLat: 0,
startLon: 0,
startZoom: 1,
widenFactor: 0.05,
socialImage: "",
layers: [{
id: "testlayer",
name: "Testing layer",
minzoom: 15,
overpassTags: {and: ["highway=residential"]},
title: "Some Title",
description: {"en": "Some Description"},
icon: {render: {en: "./assets/pencil.svg"}},
width: {render: {en: "5"}},
tagRenderings: [{
render: {"en":"Test Rendering"}
}]
}]
}
}
public static createEmptyTagRendering(): TagRenderingConfigJson {
return {};
}
}

View file

@ -9,24 +9,37 @@ import {TextField} from "../Input/TextField";
import {InputElement} from "../Input/InputElement";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import {CheckBox} from "../Input/CheckBox";
import {MultiTagInput} from "../Input/MultiTagInput";
import {AndOrTagInput} from "../Input/AndOrTagInput";
import TagRenderingPanel from "./TagRenderingPanel";
import {GenerateEmpty} from "./GenerateEmpty";
import {DropDown} from "../Input/DropDown";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
import {MultiInput} from "../Input/MultiInput";
import {Tag} from "../../Logic/Tags";
import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson";
/**
* Shows the configuration for a single layer
*/
export default class LayerPanel extends UIElement {
private _config: UIEventSource<LayoutConfigJson>;
private readonly _config: UIEventSource<LayoutConfigJson>;
private settingsTable: UIElement;
private readonly settingsTable: UIElement;
private readonly renderingOptions: UIElement;
private deleteButton: UIElement;
private readonly deleteButton: UIElement;
public readonly selectedTagRendering: UIEventSource<TagRenderingPanel>
= new UIEventSource<TagRenderingPanel>(undefined);
private tagRenderings: UIElement;
constructor(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<string[]>,
index: number,
currentlySelected: UIEventSource<SingleSetting<any>>) {
super(undefined);
super();
this._config = config;
this.renderingOptions = this.setupRenderOptions(config, languages, index, currentlySelected);
const actualDeleteButton = new SubtleButton(
"./assets/delete.svg",
@ -70,17 +83,120 @@ export default class LayerPanel extends UIElement {
setting(TextField.StringInput(), "id", "Id", "An identifier for this layer<br/>This should be a simple, lowercase, human readable string that is used to identify the layer."),
setting(new MultiLingualTextFields(languages), "title", "Title", "The human-readable name of this layer<br/>Used in the layer control panel and the 'Personal theme'"),
setting(new MultiLingualTextFields(languages, true), "description", "Description", "A description for this layer.<br/>Shown in the layer selections and in the personal theme"),
setting(new MultiTagInput(), "overpassTags","Overpass query",
new Combine(["The tags to load from overpass. ", MultiTagInput.tagExplanation]))
setting(TextField.NumberInput("nat", n => n < 23), "minzoom", "Minimal zoom",
"The minimum zoomlevel needed to load and show this layer."),
setting(new DropDown("", [
{value: 0, shown: "Show ways and areas as ways and lines"},
{value: 1, shown: "Show both the ways/areas and the centerpoints"},
{value: 2, shown: "Show everything as centerpoint"}]), "wayHandling", "Way handling",
"Describes how ways and areas are represented on the map: areas can be represented as the area itself, or it can be converted into the centerpoint"),
setting(new AndOrTagInput(), "overpassTags", "Overpass query",
"The tags of the objects to load from overpass"),
],
currentlySelected
currentlySelected);
const self = this;
const tagRenderings = new MultiInput<TagRenderingConfigJson>("Add a tag rendering/question",
() => ({}),
() => {
const tagPanel = new TagRenderingPanel(languages, currentlySelected)
self.registerTagRendering(tagPanel);
return tagPanel;
});
tagRenderings.GetValue().addCallback(
tagRenderings => {
(config.data.layers[index] as LayerConfigJson).tagRenderings = tagRenderings;
config.ping();
}
)
;
function loadTagRenderings() {
const values = (config.data.layers[index] as LayerConfigJson).tagRenderings;
const renderings: TagRenderingConfigJson[] = [];
for (const value of values) {
if (typeof (value) !== "string") {
renderings.push(value);
}
}
tagRenderings.GetValue().setData(renderings);
}
loadTagRenderings();
this.tagRenderings = tagRenderings;
}
private setupRenderOptions(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<string[]>,
index: number,
currentlySelected: UIEventSource<SingleSetting<any>>): UIElement {
const iconSelect = new TagRenderingPanel(
languages, currentlySelected,
{
title: "Icon",
description: "A visual representation for this layer and for the points on the map.",
disableQuestions: true
});
const size = new TagRenderingPanel(languages, currentlySelected,
{
title: "Icon Size",
description: "The size of the icons on the map in pixels. Can vary based on the tagging",
disableQuestions: true
});
const color = new TagRenderingPanel(languages, currentlySelected,
{
title: "Way and area color",
description: "The color or a shown way or area. Can vary based on the tagging",
disableQuestions: true
});
const stroke = new TagRenderingPanel(languages, currentlySelected,
{
title: "Stroke width",
description: "The width of lines representing ways and the outline of areas. Can vary based on the tags",
disableQuestions: true
});
this.registerTagRendering(iconSelect);
this.registerTagRendering(size);
this.registerTagRendering(color);
this.registerTagRendering(stroke);
function setting(input: InputElement<any>, path, isIcon: boolean = false): SingleSetting<TagRenderingConfigJson> {
return new SingleSetting(config, input, ["layers", index, path], undefined, undefined)
}
return new SettingsTable([
setting(iconSelect, "icon"),
setting(size, "size"),
setting(color, "color"),
setting(stroke, "stroke")
], currentlySelected);
}
private registerTagRendering(
tagRenderingPanel: TagRenderingPanel) {
tagRenderingPanel.IsHovered().addCallback(isHovering => {
if (!isHovering) {
return;
}
this.selectedTagRendering.setData(tagRenderingPanel);
})
}
InnerRender(): string {
return new Combine([
"<h2>General layer settings</h2>",
this.settingsTable,
"<h2>Map rendering options</h2>",
this.renderingOptions,
"<h2>Tag rendering and questions</h2>",
this.tagRenderings,
"<h2>Layer delete</h2>",
this.deleteButton
]).Render();
}

View file

@ -0,0 +1,64 @@
import {InputElement} from "../Input/InputElement";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import {AndOrTagInput} from "../Input/AndOrTagInput";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import {DropDown} from "../Input/DropDown";
export default class MappingInput extends InputElement<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }> {
private readonly _value: UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }>;
private readonly _panel: UIElement;
constructor(languages: UIEventSource<any>, disableQuestions: boolean = false) {
super();
const currentSelected = new UIEventSource<SingleSetting<any>>(undefined);
this._value = new UIEventSource<{ if: AndOrTagConfigJson, then: any, hideInAnswer?: boolean }>({
if: undefined,
then: undefined
});
const self = this;
function setting(inputElement: InputElement<any>, path: string, name: string, description: string | UIElement) {
return new SingleSetting(self._value, inputElement, path, name, description);
}
const withQuestions = [setting(new DropDown("",
[{value: false, shown: "Can be used as answer"}, {value: true, shown: "Not an answer option"}]),
"hideInAnswer", "Answer option",
"Sometimes, multiple tags for the same meaning are used (e.g. <span class='literal-code'>access=yes</span> and <span class='literal-code'>access=public</span>)." +
"Use this toggle to disable an anwer. Alternatively an implied/assumed rendering can be used. In order to do this:" +
"use a single tag in the 'if' with <i>no</i> value defined, e.g. <span class='literal-code'>indoor=</span>. The mapping will then be shown as default until explicitly changed"
)];
this._panel = new SettingsTable([
setting(new AndOrTagInput(), "if", "If matches", "If this condition matches, the template <b>then</b> below will be used"),
setting(new MultiLingualTextFields(languages),
"then", "Then show", "If the condition above matches, this template <b>then</b> below will be shown to the user."),
...(disableQuestions ? [] : withQuestions)
], currentSelected).SetClass("bordered tag-mapping");
}
InnerRender(): string {
return this._panel.Render();
}
GetValue(): UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }> {
return this._value;
}
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsValid(t: { if: AndOrTagConfigJson; then: any; hideInAnswer: boolean }): boolean {
return false;
}
}

View file

@ -1,27 +1,33 @@
import SingleSetting from "./SingleSetting";
import {UIElement} from "../UIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {InputElement} from "../Input/InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import PageSplit from "../Base/PageSplit";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
export default class SettingsTable extends UIElement {
private _col1: UIElement[] = [];
private _col2: InputElement<any>[] = [];
private _col2: UIElement[] = [];
public selectedSetting: UIEventSource<SingleSetting<any>>;
constructor(elements: SingleSetting<any>[],
constructor(elements: (SingleSetting<any> | string)[],
currentSelectedSetting: UIEventSource<SingleSetting<any>>) {
super(undefined);
const self = this;
this.selectedSetting = currentSelectedSetting ?? new UIEventSource<SingleSetting<any>>(undefined);
for (const element of elements) {
let title: UIElement = new FixedUiElement(element._name);
if(typeof element === "string"){
this._col1.push(new FixedUiElement(element));
this._col2.push(null);
continue;
}
let title: UIElement = element._name === undefined ? null : new FixedUiElement(element._name);
this._col1.push(title);
this._col2.push(element._value);
element._value.SetStyle("display:block");
element._value.IsSelected.addCallback(isSelected => {
if (isSelected) {
self.selectedSetting.setData(element);
@ -34,13 +40,19 @@ export default class SettingsTable extends UIElement {
}
InnerRender(): string {
let html = "";
let elements = [];
for (let i = 0; i < this._col1.length; i++) {
html += `<tr><td>${this._col1[i].Render()}</td><td>${this._col2[i].Render()}</td></tr>`
if(this._col1[i] !== null && this._col2[i] !== null){
elements.push(new PageSplit(this._col1[i], this._col2[i], 25));
}else if(this._col1[i] !== null){
elements.push(this._col1[i])
}else{
elements.push(this._col2[i])
}
}
return `<table><tr>${html}</tr></table>`;
return new Combine(elements).Render();
}
}

View file

@ -1,4 +1,3 @@
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import {UIEventSource} from "../../Logic/UIEventSource";
import {InputElement} from "../Input/InputElement";
import {UIElement} from "../UIElement";
@ -12,7 +11,7 @@ export default class SingleSetting<T> {
public _description: UIElement;
public _options: { showIconPreview?: boolean };
constructor(config: UIEventSource<LayoutConfigJson>,
constructor(config: UIEventSource<any>,
value: InputElement<T>,
path: string | (string | number)[],
name: string,
@ -47,11 +46,17 @@ export default class SingleSetting<T> {
// We have to rewalk every time as parts might be new
let configPart = config.data;
for (const pathPart of path) {
configPart = configPart[pathPart];
if (configPart === undefined) {
console.warn("Lost the way for path ", path)
return;
let newConfigPart = configPart[pathPart];
if (newConfigPart === undefined) {
console.warn("Lost the way for path ", path, " - creating entry")
if (typeof (pathPart) === "string") {
configPart[pathPart] = {};
} else {
configPart[pathPart] = [];
}
newConfigPart = configPart[pathPart];
}
configPart = newConfigPart;
}
configPart[lastPart] = value;
config.ping();
@ -66,7 +71,6 @@ export default class SingleSetting<T> {
}
}
const loadedValue = configPart[lastPart];
if (loadedValue !== undefined) {
value.GetValue().setData(loadedValue);
}
@ -79,6 +83,8 @@ export default class SingleSetting<T> {
}
}

View file

@ -0,0 +1,103 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {InputElement} from "../Input/InputElement";
import SingleSetting from "./SingleSetting";
import SettingsTable from "./SettingsTable";
import {TextField, ValidatedTextField} from "../Input/TextField";
import Combine from "../Base/Combine";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import {AndOrTagInput} from "../Input/AndOrTagInput";
import {MultiTagInput} from "../Input/MultiTagInput";
import {MultiInput} from "../Input/MultiInput";
import MappingInput from "./MappingInput";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
export default class TagRenderingPanel extends InputElement<TagRenderingConfigJson> {
private intro: UIElement;
private settingsTable: UIElement;
public IsImage = false;
private readonly _value: UIEventSource<TagRenderingConfigJson>;
public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; };
constructor(languages: UIEventSource<string[]>,
currentlySelected: UIEventSource<SingleSetting<any>>,
options?: {
title?: string,
description?: string,
disableQuestions?: boolean,
isImage?: boolean
}) {
super();
this.SetClass("bordered");
this.SetClass("min-height");
this.options = options ?? {};
this.intro = new Combine(["<h3>", options?.title ?? "TagRendering", "</h3>", options?.description ?? ""])
this.IsImage = options?.isImage ?? false;
const value = new UIEventSource<TagRenderingConfigJson>({});
this._value = value;
function setting(input: InputElement<any>, id: string | string[], name: string, description: string | UIElement): SingleSetting<any> {
return new SingleSetting<any>(value, input, id, name, description);
}
const questionSettings = [
setting(new MultiLingualTextFields(languages), "question", "Question", "If the key or mapping doesn't match, this question is asked"),
setting(new AndOrTagInput(), "condition", "Condition",
"Only show this tag rendering if these tags matches. Optional field.<br/>Note that the Overpass-tags are already always included in this object"),
"<h3>Freeform key</h3>",
setting(TextField.KeyInput(), ["freeform", "key"], "Freeform key<br/>",
"If specified, the rendering will search if this key is present." +
"If it is, the rendering above will be used to display the element.<br/>" +
"The rendering will go into question mode if <ul><li>this key is not present</li><li>No single mapping matches</li><li>A question is given</li>"),
setting(ValidatedTextField.TypeDropdown(), ["freeform", "type"], "Freeform type",
"The type of this freeform text field, in order to validate"),
setting(new MultiTagInput(), ["freeform", "addExtraTags"], "Extra tags on freeform",
"When the freeform text field is used, the user might mean a predefined key. This field allows to add extra tags, e.g. <span class='literal-code'>fixme=User used a freeform field - to check</span>"),
];
const settings: (string | SingleSetting<any>)[] = [
setting(new MultiLingualTextFields(languages), "render", "Value to show", " Renders this value. Note that <span class='literal-code'>{key}</span>-parts are substituted by the corresponding values of the element. If neither 'textFieldQuestion' nor 'mappings' are defined, this text is simply shown as default value."),
...(options?.disableQuestions ? [] : questionSettings),
"<h3>Mappings</h3>",
setting(new MultiInput<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }>("Add a mapping",
() => ({if: undefined, then: undefined}),
() => new MappingInput(languages, options?.disableQuestions ?? false)), "mappings",
"Mappings", "")
];
this.settingsTable = new SettingsTable(settings, currentlySelected);
}
InnerRender(): string {
return new Combine([
this.intro,
this.settingsTable]).Render();
}
GetValue(): UIEventSource<TagRenderingConfigJson> {
return this._value;
}
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsValid(t: TagRenderingConfigJson): boolean {
return false;
}
}

View file

@ -0,0 +1,15 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import TagRenderingPanel from "./TagRenderingPanel";
export default class TagRenderingPreview extends UIElement{
constructor(selectedTagRendering: UIEventSource<TagRenderingPanel>) {
super(selectedTagRendering);
}
InnerRender(): string {
return "";
}
}

View file

@ -3,88 +3,162 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import TagInput from "./TagInput";
import {FixedUiElement} from "../Base/FixedUiElement";
import {CheckBox} from "./CheckBox";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import {MultiTagInput} from "./MultiTagInput";
import {FormatNumberOptions} from "libphonenumber-js";
export class AndOrTagInput extends InputElement<(string | AndOrTagInput)[]> {
class AndOrConfig implements AndOrTagConfigJson {
public and: (string | AndOrTagConfigJson)[] = undefined;
public or: (string | AndOrTagConfigJson)[] = undefined;
}
private readonly _value: UIEventSource<string[]>;
export class AndOrTagInput extends InputElement<AndOrTagConfigJson> {
private readonly _rawTags = new MultiTagInput();
private readonly _subAndOrs: AndOrTagInput[] = [];
private readonly _isAnd: UIEventSource<boolean> = new UIEventSource<boolean>(true);
private readonly _isAndButton;
private readonly _addBlock: UIElement;
private readonly _value: UIEventSource<AndOrConfig> = new UIEventSource<AndOrConfig>(undefined);
public bottomLeftButton: UIElement;
IsSelected: UIEventSource<boolean>;
private elements: UIElement[] = [];
private inputELements: (InputElement<string> | InputElement<AndOrTagInput>)[] = [];
private addTag: UIElement;
constructor(value: UIEventSource<string[]> = new UIEventSource<string[]>([])) {
super(undefined);
this._value = value;
this.addTag = new SubtleButton("./assets/addSmall.svg", "Add a tag")
.SetClass("small-button")
.onClick(() => {
this.IsSelected.setData(true);
value.data.push("");
value.ping();
});
constructor() {
super();
const self = this;
value.map<number>((tags: string[]) => tags.length).addCallback(() => self.createElements());
this.createElements();
this._isAndButton = new CheckBox(
new SubtleButton("./assets/ampersand.svg", null).SetClass("small-button"),
new SubtleButton("./assets/or.svg", null).SetClass("small-button"),
this._isAnd);
this._value.addCallback(tags => self.load(tags));
this.IsSelected = new UIEventSource<boolean>(false);
}
this._addBlock =
new SubtleButton("./assets/addSmall.svg", "Add an and/or-expression")
.SetClass("small-button")
.onClick(() => {self.createNewBlock()});
this._isAnd.addCallback(() => self.UpdateValue());
this._rawTags.GetValue().addCallback(() => {
self.UpdateValue()
});
this.IsSelected = this._rawTags.IsSelected;
this._value.addCallback(tags => self.loadFromValue(tags));
private load(tags: string[]) {
if (tags === undefined) {
return;
}
for (let i = 0; i < tags.length; i++) {
console.log("Setting tag ", i)
this.inputELements[i].GetValue().setData(tags[i]);
}
}
private UpdateIsSelected(){
this.IsSelected.setData(this.inputELements.map(input => input.IsSelected.data).reduce((a,b) => a && b))
}
private createElements() {
this.inputELements = [];
this.elements = [];
for (let i = 0; i < this._value.data.length; i++) {
let tag = this._value.data[i];
const input = new TagInput(new UIEventSource<string>(tag));
input.GetValue().addCallback(tag => {
console.log("Writing ", tag)
this._value.data[i] = tag;
this._value.ping();
}
);
this.inputELements.push(input);
input.IsSelected.addCallback(() => this.UpdateIsSelected());
const deleteBtn = new FixedUiElement("<img src='./assets/delete.svg' style='max-width: 1.5em; margin-left: 5px;'>")
.onClick(() => {
this._value.data.splice(i, 1);
this._value.ping();
});
this.elements.push(new Combine([input, deleteBtn, "<br/>"]).SetClass("tag-input-row"))
}
private createNewBlock(){
const inputEl = new AndOrTagInput();
inputEl.GetValue().addCallback(() => this.UpdateValue());
const deleteButton = this.createDeleteButton(inputEl.id);
inputEl.bottomLeftButton = deleteButton;
this._subAndOrs.push(inputEl);
this.Update();
}
InnerRender(): string {
return new Combine([...this.elements, this.addTag]).SetClass("bordered").Render();
private createDeleteButton(elementId: string): UIElement {
const self = this;
return new SubtleButton("./assets/delete.svg", null).SetClass("small-button")
.onClick(() => {
for (let i = 0; i < self._subAndOrs.length; i++) {
if (self._subAndOrs[i].id === elementId) {
self._subAndOrs.splice(i, 1);
self.Update();
self.UpdateValue();
return;
}
}
});
}
private loadFromValue(value: AndOrTagConfigJson) {
this._isAnd.setData(value.and !== undefined);
const tags = value.and ?? value.or;
const rawTags: string[] = [];
const subTags: AndOrTagConfigJson[] = [];
for (const tag of tags) {
if (typeof (tag) === "string") {
rawTags.push(tag);
} else {
subTags.push(tag);
}
}
for (let i = 0; i < rawTags.length; i++) {
if (this._rawTags.GetValue().data[i] !== rawTags[i]) {
// For some reason, 'setData' isn't stable as the comparison between the lists fails
// Probably because we generate a new list object every timee
// So we compare again here and update only if we find a difference
this._rawTags.GetValue().setData(rawTags);
break;
}
}
while(this._subAndOrs.length < subTags.length){
this.createNewBlock();
}
for (let i = 0; i < subTags.length; i++){
let subTag = subTags[i];
this._subAndOrs[i].GetValue().setData(subTag);
}
IsValid(t: string[]): boolean {
return false;
}
GetValue(): UIEventSource<string[]> {
private UpdateValue() {
const tags: (string | AndOrTagConfigJson)[] = [];
tags.push(...this._rawTags.GetValue().data);
for (const subAndOr of this._subAndOrs) {
const subAndOrData = subAndOr._value.data;
if (subAndOrData === undefined) {
continue;
}
console.log(subAndOrData);
tags.push(subAndOrData);
}
const tagConfig = new AndOrConfig();
if (this._isAnd.data) {
tagConfig.and = tags;
} else {
tagConfig.or = tags;
}
this._value.setData(tagConfig);
}
GetValue(): UIEventSource<AndOrTagConfigJson> {
return this._value;
}
InnerRender(): string {
const leftColumn = new Combine([
this._isAndButton,
"<br/>",
this.bottomLeftButton ?? ""
]);
const tags = new Combine([
this._rawTags,
...this._subAndOrs,
this._addBlock
]).Render();
return `<span class="bordered"><table><tr><td>${leftColumn.Render()}</td><td>${tags}</td></tr></table></span>`;
}
IsValid(t: AndOrTagConfigJson): boolean {
return true;
}
}

89
UI/Input/MultiInput.ts Normal file
View file

@ -0,0 +1,89 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import {FixedUiElement} from "../Base/FixedUiElement";
export class MultiInput<T> extends InputElement<T[]> {
private readonly _value: UIEventSource<T[]>;
IsSelected: UIEventSource<boolean>;
private elements: UIElement[] = [];
private inputELements: InputElement<T>[] = [];
private addTag: UIElement;
constructor(
addAElement: string,
newElement: (() => T),
createInput: (() => InputElement<T>),
value: UIEventSource<T[]> = new UIEventSource<T[]>([])) {
super(undefined);
this._value = value;
this.addTag = new SubtleButton("./assets/addSmall.svg", addAElement)
.SetClass("small-button")
.onClick(() => {
this.IsSelected.setData(true);
value.data.push(newElement());
value.ping();
});
const self = this;
value.map<number>((tags: string[]) => tags.length).addCallback(() => self.createElements(createInput));
this.createElements(createInput);
this._value.addCallback(tags => self.load(tags));
this.IsSelected = new UIEventSource<boolean>(false);
}
private load(tags: T[]) {
if (tags === undefined) {
return;
}
for (let i = 0; i < tags.length; i++) {
this.inputELements[i].GetValue().setData(tags[i]);
}
}
private UpdateIsSelected(){
this.IsSelected.setData(this.inputELements.map(input => input.IsSelected.data).reduce((a,b) => a && b))
}
private createElements(createInput: (() => InputElement<T>)) {
this.inputELements.splice(0, this.inputELements.length);
this.elements = [];
const self = this;
for (let i = 0; i < this._value.data.length; i++) {
let tag = this._value.data[i];
const input = createInput();
input.GetValue().addCallback(tag => {
self._value.data[i] = tag;
self._value.ping();
}
);
this.inputELements.push(input);
input.IsSelected.addCallback(() => this.UpdateIsSelected());
const deleteBtn = new FixedUiElement("<img src='./assets/delete.svg' style='max-width: 1.5em; margin-left: 5px;'>")
.onClick(() => {
self._value.data.splice(i, 1);
self._value.ping();
});
this.elements.push(new Combine([input, deleteBtn, "<br/>"]).SetClass("tag-input-row"))
}
this.Update();
}
InnerRender(): string {
return new Combine([...this.elements, this.addTag]).Render();
}
IsValid(t: T[]): boolean {
return false;
}
GetValue(): UIEventSource<T[]> {
return this._value;
}
}

View file

@ -5,88 +5,17 @@ import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import TagInput from "./TagInput";
import {FixedUiElement} from "../Base/FixedUiElement";
import {MultiInput} from "./MultiInput";
export class MultiTagInput extends InputElement<string[]> {
public static tagExplanation: UIElement =
new FixedUiElement("<h3>How to use the tag-element</h3>")
private readonly _value: UIEventSource<string[]>;
IsSelected: UIEventSource<boolean>;
private elements: UIElement[] = [];
private inputELements: InputElement<string>[] = [];
private addTag: UIElement;
export class MultiTagInput extends MultiInput<string> {
constructor(value: UIEventSource<string[]> = new UIEventSource<string[]>([])) {
super(undefined);
this._value = value;
this.addTag = new SubtleButton("./assets/addSmall.svg", "Add a tag")
.SetClass("small-button")
.onClick(() => {
this.IsSelected.setData(true);
value.data.push("");
value.ping();
});
const self = this;
value.map<number>((tags: string[]) => tags.length).addCallback(() => self.createElements());
this.createElements();
this._value.addCallback(tags => self.load(tags));
this.IsSelected = new UIEventSource<boolean>(false);
}
private load(tags: string[]) {
if (tags === undefined) {
return;
}
for (let i = 0; i < tags.length; i++) {
console.log("Setting tag ", i)
this.inputELements[i].GetValue().setData(tags[i]);
}
}
private UpdateIsSelected(){
this.IsSelected.setData(this.inputELements.map(input => input.IsSelected.data).reduce((a,b) => a && b))
}
private createElements() {
this.inputELements = [];
this.elements = [];
for (let i = 0; i < this._value.data.length; i++) {
let tag = this._value.data[i];
const input = new TagInput(new UIEventSource<string>(tag));
input.GetValue().addCallback(tag => {
console.log("Writing ", tag)
this._value.data[i] = tag;
this._value.ping();
}
);
this.inputELements.push(input);
input.IsSelected.addCallback(() => this.UpdateIsSelected());
const deleteBtn = new FixedUiElement("<img src='./assets/delete.svg' style='max-width: 1.5em; margin-left: 5px;'>")
.onClick(() => {
this._value.data.splice(i, 1);
this._value.ping();
});
this.elements.push(new Combine([input, deleteBtn, "<br/>"]).SetClass("tag-input-row"))
}
this.Update();
}
InnerRender(): string {
return new Combine([...this.elements, this.addTag]).SetClass("bordered").Render();
}
IsValid(t: string[]): boolean {
return false;
}
GetValue(): UIEventSource<string[]> {
return this._value;
super("Add a new tag",
() => "",
() => new TagInput(),
value
);
}
}

View file

@ -2,6 +2,7 @@ import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
export class RadioButton<T> extends InputElement<T> {
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _selectedElementIndex: UIEventSource<number>
= new UIEventSource<number>(null);
@ -26,16 +27,16 @@ export class RadioButton<T> extends InputElement<T> {
return elements[selectedIndex].GetValue()
}
}
), elements.map(e => e.GetValue()));
), elements.map(e => e?.GetValue()));
this.value.addCallback((t) => {
self.ShowValue(t);
self?.ShowValue(t);
})
for (let i = 0; i < elements.length; i++) {
// If an element is clicked, the radio button corresponding with it should be selected as well
elements[i].onClick(() => {
elements[i]?.onClick(() => {
self._selectedElementIndex.setData(i);
});
}

View file

@ -18,16 +18,7 @@ export default class SingleTagInput extends InputElement<string> {
super(undefined);
this._value = value ?? new UIEventSource<string>(undefined);
this.key = new TextField({
placeholder: "key",
fromString: str => {
if (str?.match(/^[a-zA-Z][a-zA-Z0-9:]*$/)) {
return str;
}
return undefined
},
toString: str => str
});
this.key = TextField.KeyInput();
this.value = new TextField<string>({
placeholder: "value - if blank, matches if key is NOT present",
@ -95,7 +86,8 @@ export default class SingleTagInput extends InputElement<string> {
InnerRender(): string {
return new Combine([
this.key, this.operator, this.value
]).Render();
]).SetStyle("display:flex")
.Render();
}

View file

@ -4,8 +4,33 @@ import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import * as EmailValidator from "email-validator";
import {parsePhoneNumberFromString} from "libphonenumber-js";
import {DropDown} from "./DropDown";
export class ValidatedTextField {
public static explanations = {
"string": "A basic, 255-char string",
"date": "A date",
"wikidata": "A wikidata identifier, e.g. Q42",
"int": "A number",
"nat": "A positive number",
"float": "A decimal",
"pfloat": "A positive decimal",
"email": "An email adress",
"url": "A url",
"phone": "A phone number"
}
public static TypeDropdown() : DropDown<string>{
const values : {value: string, shown: string}[] = [];
const expl = ValidatedTextField.explanations;
for(const key in expl){
values.push({value: key, shown: `${key} - ${expl[key]}`})
}
return new DropDown<string>("", values)
}
public static inputValidation = {
"$": () => true,
"string": () => true,
@ -40,6 +65,19 @@ export class TextField<T> extends InputElement<T> {
});
}
public static KeyInput(): TextField<string>{
return new TextField<string>({
placeholder: "key",
fromString: str => {
if (str?.match(/^[a-zA-Z][a-zA-Z0-9:_-]*$/)) {
return str;
}
return undefined
},
toString: str => str
});
}
public static NumberInput(type: string = "int", extraValidation: (number: Number) => boolean = undefined) : TextField<number>{
const isValid = ValidatedTextField.inputValidation[type];
extraValidation = extraValidation ?? (() => true)

View file

@ -162,6 +162,7 @@ export class ShareScreen extends UIElement {
this._iframeCode = new VariableUiElement(
url.map((url) => {
return `<span class='literal-code iframe-code-block'>
&lt;iframe src="${url}" width="100%" height="100%" title="${layout.title.InnerRender()} with MapComplete"&gt;&lt;/iframe&gt
</span>`
})
);

View file

@ -53,7 +53,7 @@ export class SimpleAddUI extends UIElement {
if (typeof (preset.icon) !== "string") {
const tags = Utils.MergeTags(TagUtils.KVtoProperties(preset.tags), {id:"node/-1"});
icon = preset.icon.GetContent(tags);
icon = preset.icon.GetContent(tags).txt;
} else {
icon = preset.icon;
}
@ -193,7 +193,7 @@ export class SimpleAddUI extends UIElement {
return new Combine([header, Translations.t.general.add.stillLoading]).Render()
}
return header.Render() + new Combine(this._addButtons, "add-popup-all-buttons").Render();
return header.Render() + new Combine(this._addButtons).SetClass("add-popup-all-buttons").Render();
}

View file

@ -1,15 +1,19 @@
import {UIEventSource} from "../Logic/UIEventSource";
export abstract class UIElement extends UIEventSource<string>{
export abstract class UIElement extends UIEventSource<string> {
private static nextId: number = 0;
public readonly id: string;
public readonly _source: UIEventSource<any>;
private clss: string[] = []
private style: string;
private _hideIfEmpty = false;
public dumbMode = false;
/**
* In the 'deploy'-step, some code needs to be run by ts-node.
* However, ts-node crashes when it sees 'document'. When running from console, we flag this and disable all code where document is needed.
@ -30,6 +34,7 @@ export abstract class UIElement extends UIEventSource<string>{
if (source === undefined) {
return this;
}
this.dumbMode = false;
const self = this;
source.addCallback(() => {
self.Update();
@ -40,24 +45,56 @@ export abstract class UIElement extends UIEventSource<string>{
private _onClick: () => void;
public onClick(f: (() => void)) {
this.dumbMode = false;
this._onClick = f;
this.SetClass("clickable")
this.Update();
return this;
}
private _onHover: UIEventSource<boolean>;
public IsHovered(): UIEventSource<boolean> {
this.dumbMode = false;
if (this._onHover !== undefined) {
return this._onHover;
}
// Note: we just save it. 'Update' will register that an eventsource exist and install the necessary hooks
this._onHover = new UIEventSource<boolean>(false);
return this._onHover;
}
Update(): void {
if(UIElement.runningFromConsole){
if (UIElement.runningFromConsole) {
return;
}
let element = document.getElementById(this.id);
if (element === undefined || element === null) {
// The element is not painted
if (this.dumbMode) {
// We update all the children anyway
for (const i in this) {
const child = this[i];
if (child instanceof UIElement) {
child.Update();
} else if (child instanceof Array) {
for (const ch of child) {
if (ch instanceof UIElement) {
ch.Update();
}
}
}
}
}
return;
}
this.setData(this.InnerRender());
element.innerHTML = this.data;
if (this._hideIfEmpty) {
if (element.innerHTML === "") {
element.parentElement.style.display = "none";
@ -70,7 +107,7 @@ export abstract class UIElement extends UIEventSource<string>{
const self = this;
element.onclick = (e) => {
// @ts-ignore
if(e.consumed){
if (e.consumed) {
return;
}
self._onClick();
@ -81,6 +118,12 @@ export abstract class UIElement extends UIEventSource<string>{
element.style.cursor = "pointer";
}
if (this._onHover !== undefined) {
const self = this;
element.addEventListener('mouseover', () => self._onHover.setData(true));
element.addEventListener('mouseout', () => self._onHover.setData(false));
}
this.InnerUpdate(element);
for (const i in this) {
@ -108,10 +151,18 @@ export abstract class UIElement extends UIEventSource<string>{
}
Render(): string {
return `<span class='uielement ${this.clss.join(" ")}' id='${this.id}'>${this.InnerRender()}</span>`
if (this.dumbMode) {
return this.InnerRender();
}
let style = "";
if (this.style !== undefined && this.style !== "") {
style = `style="${this.style}"`;
}
return `<span class='uielement ${this.clss.join(" ")}' ${style} id='${this.id}'>${this.InnerRender()}</span>`
}
AttachTo(divId: string) {
this.dumbMode = false;
let element = document.getElementById(divId);
if (element === null) {
throw "SEVERE: could not attach UIElement to " + divId;
@ -143,6 +194,7 @@ export abstract class UIElement extends UIEventSource<string>{
}
public SetClass(clss: string): UIElement {
this.dumbMode = false;
if (this.clss.indexOf(clss) < 0) {
this.clss.push(clss);
}
@ -150,14 +202,13 @@ export abstract class UIElement extends UIEventSource<string>{
return this;
}
public RemoveClass(clss: string): UIElement {
if (this.clss.indexOf(clss) >= 0) {
this.clss = this.clss.splice(this.clss.indexOf(clss), 1);
}
public SetStyle(style: string): UIElement {
this.dumbMode = false;
this.style = style;
this.Update();
return this;
}
}

View file

@ -52,8 +52,8 @@ export default class Translation extends UIElement {
for (const i in this.translations) {
return this.translations[i]; // Return a random language
}
console.log("Missing language ",Locale.language.data,"for",this.translations)
return "Missing translation"
console.error("Missing language ",Locale.language.data,"for",this.translations)
return undefined;
}
InnerRender(): string {