Add documentation

This commit is contained in:
Misode
2020-05-30 02:53:22 +02:00
parent ba5f9638c0
commit c91e0a5300
14 changed files with 241 additions and 1 deletions

View File

@@ -6,6 +6,9 @@ export interface Registry<T> {
get(id: string): T
}
/**
* Registry for schemas
*/
class SchemaRegistry implements Registry<INode<any>> {
private registry: { [id: string]: INode<any> } = {}
@@ -22,6 +25,9 @@ class SchemaRegistry implements Registry<INode<any>> {
}
}
/**
* Registry for collections
*/
class CollectionRegistry implements Registry<string[]> {
private registry: { [id: string]: string[] } = {}
@@ -38,6 +44,9 @@ class CollectionRegistry implements Registry<string[]> {
}
}
/**
* Registry for locales
*/
export interface Locale {
[key: string]: string
}
@@ -46,6 +55,11 @@ class LocaleRegistry implements Registry<Locale> {
private registry: { [id: string]: Locale } = {}
language: string = ''
/**
*
* @param id locale identifier
* @param locale object mapping keys to translations
*/
register(id: string, locale: Locale): void {
this.registry[id] = locale
}
@@ -67,6 +81,14 @@ export const SCHEMAS = new SchemaRegistry()
export const COLLECTIONS = new CollectionRegistry()
export const LOCALES = new LocaleRegistry()
/**
* Gets the locale of a key from the locale registry.
*
* @param key string or path that refers to a locale ID.
* If a string is given, an exact match is required.
* If a path is given, it finds the longest match at the end.
* @returns undefined if the key isn't found for the selected language
*/
export const locale = (key: string | Path) => {
if (typeof key === 'string') {
return LOCALES.getLocale(key) ?? key

View File

@@ -5,30 +5,52 @@ export interface ModelListener {
invalidated(model: DataModel): void
}
/**
* Holding the data linked to a given schema
*/
export class DataModel {
data: any
schema: INode<any>
/** A list of listeners that want to be notified when the model is invalidated */
listeners: ModelListener[]
/**
* @param schema node to use as schema for this model
*/
constructor(schema: INode<any>) {
this.schema = schema
this.data = schema.default()
this.listeners = []
}
/**
* @param listener the listener to notify when the model is invalidated
*/
addListener(listener: ModelListener) {
this.listeners.push(listener)
}
/**
* Force notify all listeners that the model is invalidated
*/
invalidate() {
this.listeners.forEach(listener => listener.invalidated(this))
}
/**
* Resets the full data and notifies listeners
* @param value new model data
*/
reset(value: any) {
this.data = value
this.invalidate()
}
/**
* Gets the data at a specified path
* @param path path at which to find the data
* @returns undefined, if the the path does not exist in the data
*/
get(path: Path) {
let node = this.data;
for (let index of path) {
@@ -38,6 +60,11 @@ export class DataModel {
return node
}
/**
* Updates the date on a path. Node will be removed when value is undefined
* @param path path to update
* @param value new data at the specified path
*/
set(path: Path, value: any) {
let node = this.data;
for (let index of path.pop()) {

View File

@@ -2,23 +2,41 @@ import { DataModel } from "./DataModel"
export type PathElement = (string | number)
/**
* Immutable helper class to represent a path in data
* @implements {Iterable<PathElement>}
*/
export class Path implements Iterable<PathElement> {
private arr: PathElement[]
model?: DataModel
/**
* @param arr Initial array of path elements. Empty if not given
* @param model Model attached to this path
*/
constructor(arr?: PathElement[], model?: DataModel) {
this.arr = arr ?? []
this.model = model
}
/**
* The last element of this path
*/
last(): PathElement {
return this.arr[this.arr.length - 1]
}
/**
* A new path with the last element removed
*/
pop(): Path {
return new Path(this.arr.slice(0, -1), this.model)
}
/**
* A new path with an element added at the end
* @param element element to push at the end of the array
*/
push(element: PathElement): Path {
return new Path([...this.arr, element], this.model)
}
@@ -31,10 +49,18 @@ export class Path implements Iterable<PathElement> {
return this.arr
}
/**
* Attaches a model to this path and all paths created from this
* @param model
*/
withModel(model: DataModel): Path {
return new Path([...this.arr], model)
}
/**
* Gets the data from the model if it was attached
* @returns undefined, if no model was attached
*/
get(): any {
return this.model?.get(this)
}

View File

@@ -2,8 +2,11 @@ import { DataModel } from "../model/DataModel"
import { Path } from "../model/Path"
import { TreeView } from "../view/TreeView"
/**
* Schema node that supports some standard transformations
*/
export interface INode<T> {
default: IDefault<T>
default: (value?: T) => T | undefined
transform: (path: Path, value: T) => any
enabled: (path: Path, model: DataModel) => boolean
render: (path: Path, value: T, view: TreeView, options?: RenderOptions) => string
@@ -36,12 +39,20 @@ export interface NodeMods<T> {
force?: IForce
}
/**
* Basic implementation of the nodes
*
* h
*/
export abstract class AbstractNode<T> implements INode<T> {
defaultMod: IDefault<T>
transformMod: ITransform<T>
enableMod: IEnable
forceMod: IForce
/**
* @param mods modifiers of the default transformations
*/
constructor(mods?: NodeMods<T>) {
this.defaultMod = mods?.default ?? ((v) => v)
this.transformMod = mods?.transform ?? ((v) => v)
@@ -49,6 +60,12 @@ export abstract class AbstractNode<T> implements INode<T> {
this.forceMod = mods?.force ?? (() => false)
}
/**
* Runs when the element is mounted to the DOM
* @param el mounted element
* @param path data path that this node represents
* @param view corresponding tree view where this was mounted
*/
mounted(el: Element, path: Path, view: TreeView) {
el.addEventListener('change', evt => {
this.updateModel(el, path, view.model)
@@ -56,18 +73,37 @@ export abstract class AbstractNode<T> implements INode<T> {
})
}
/**
* Runs when the DOM element 'change' event is called
* @param el mounted element
* @param path data path that this node represents
* @param model corresponding model
*/
updateModel(el: Element, path: Path, model: DataModel) {}
/**
* The default value of this node
* @param value optional original value
*/
default(value?: T) {
return this.defaultMod(value)
}
/**
* Transforms the data model to the final output format
* @param
*/
transform(path: Path, value: T) {
if (!this.enabled(path)) return undefined
if (value === undefined && this.force()) value = this.default(value)!
return this.transformMod(value)
}
/**
* Determines whether the node should be enabled for this path
* @param path
* @param model
*/
enabled(path: Path, model?: DataModel) {
if (model) path = path.withModel(model)
return this.enableMod(path.pop())
@@ -77,6 +113,14 @@ export abstract class AbstractNode<T> implements INode<T> {
return this.forceMod()
}
/**
* Wraps and renders the node
* @param path location
* @param value data used at
* @param view tree view context, containing the model
* @param options optional render options
* @returns string HTML wrapped representation of this node using the given data
*/
render(path: Path, value: T, view: TreeView, options?: RenderOptions): string {
if (!this.enabled(path, view.model)) return ''
@@ -88,7 +132,18 @@ export abstract class AbstractNode<T> implements INode<T> {
</div>`
}
/**
* Renders the node and handles events to update the model
* @param path
* @param value
* @param view tree view context, containing the model
* @param options optional render options
* @returns string HTML representation of this node using the given data
*/
abstract renderRaw(path: Path, value: T, view: TreeView, options?: RenderOptions): string
/**
* The CSS classname used in the wrapped <div>
*/
abstract getClassName(): string
}

View File

@@ -3,8 +3,14 @@ import { Path } from "../model/Path";
import { TreeView } from "../view/TreeView";
import { locale } from "../Registries";
/**
* Boolean node with two buttons for true/false
*/
export class BooleanNode extends AbstractNode<boolean> {
/**
* @param mods optional node modifiers
*/
constructor(mods?: NodeMods<boolean>) {
super({
default: () => false,

View File

@@ -4,10 +4,17 @@ import { TreeView } from '../view/TreeView'
import { Path } from '../model/Path'
import { locale } from '../Registries'
/**
* Enum node that shows a list of options to choose from
*/
export class EnumNode extends AbstractNode<string> implements StateNode<string> {
protected options: string[]
static className = 'enum-node'
/**
* @param options options to choose from in the select
* @param mods optional node modifiers or a string to be the default value
*/
constructor(options: string[], mods?: NodeMods<string> | string) {
super(typeof mods === 'string' ? {
default: () => mods,

View File

@@ -5,9 +5,16 @@ import { Path } from '../model/Path'
import { IObject } from './ObjectNode'
import { locale } from '../Registries'
/**
* List node where children can be added and removed from
*/
export class ListNode extends AbstractNode<IObject[]> {
protected children: INode<any>
/**
* @param values node used for its children
* @param mods optional node modifiers
*/
constructor(values: INode<any>, mods?: NodeMods<IObject[]>) {
super({
default: () => [],

View File

@@ -8,10 +8,19 @@ export type IMap = {
[name: string]: IObject
}
/**
* Map nodes similar to list nodes, but a string key is required to add children
*/
export class MapNode extends AbstractNode<IMap> {
protected keys: StateNode<string>
protected values: INode<any>
/**
*
* @param keys node used for the string key
* @param values node used for the map values
* @param mods optional node modifiers
*/
constructor(keys: StateNode<string>, values: INode<any>, mods?: NodeMods<IMap>) {
super({
default: () => ({}),

View File

@@ -5,16 +5,25 @@ import { TreeView } from '../view/TreeView'
import { locale } from '../Registries'
export interface NumberNodeMods extends NodeMods<number> {
/** Whether numbers should be converted to integers on input */
integer?: boolean
/** If specified, number will be capped at this minimum */
min?: number
/** If specified, number will be capped at this maximum */
max?: number
}
/**
* Configurable number node with one text field
*/
export class NumberNode extends AbstractNode<number> implements StateNode<number> {
integer: boolean
min: number
max: number
/**
* @param mods optional node modifiers
*/
constructor(mods?: NumberNodeMods) {
super({
default: () => 0,

View File

@@ -16,20 +16,31 @@ export type IObject = {
export type FilteredChildren = {
[name: string]: INode<any>
/** The field to filter on */
[Switch]?: string
/** Map of filter values to node fields */
[Case]?: NestedNodeChildren
}
export interface ObjectNodeMods extends NodeMods<object> {
/** Whether the object can be collapsed. Necessary when recursively nesting. */
collapse?: boolean
}
/**
* Object node containing fields with different types.
* Has the ability to filter fields based on a switch field.
*/
export class ObjectNode extends AbstractNode<IObject> {
fields: NodeChildren
cases: NestedNodeChildren
filter?: string
collapse?: boolean
/**
* @param fields children containing the optional switch and case
* @param mods optional node modifiers
*/
constructor(fields: FilteredChildren, mods?: ObjectNodeMods) {
super({
default: () => ({}),

View File

@@ -7,10 +7,17 @@ export interface AnyNodeMods extends NodeMods<any> {
[name: string]: any
}
/**
* Reference node. Must be used when recursively adding nodes.
*/
export class ReferenceNode extends AbstractNode<any> {
protected reference: () => INode<any>
options: RenderOptions
/**
* @param id schema id that was registered
* @param mods optional node modifiers
*/
constructor(id: string, mods?: AnyNodeMods) {
super(mods)
this.options = {

View File

@@ -5,12 +5,19 @@ import { TreeView } from '../view/TreeView'
import { locale } from '../Registries'
export interface StringNodeMods extends NodeMods<string> {
/** Whether the string can also be empty */
allowEmpty?: boolean
}
/**
* Simple string node with one text field
*/
export class StringNode extends AbstractNode<string> implements StateNode<string> {
allowEmpty: boolean
/**
* @param mods optional node modifiers
*/
constructor(mods?: StringNodeMods) {
super(mods)
this.allowEmpty = mods?.allowEmpty ?? false

View File

@@ -1,10 +1,18 @@
import { DataModel, ModelListener } from "../model/DataModel"
import { Path } from "../model/Path"
/**
* JSON representation view of the model.
* Renders the result in a <textarea>.
*/
export class SourceView implements ModelListener {
model: DataModel
target: HTMLElement
/**
* @param model data model this view represents and listens to
* @param target DOM element to render the view
*/
constructor(model: DataModel, target: HTMLElement) {
this.model = model
this.target = target
@@ -25,6 +33,10 @@ export class SourceView implements ModelListener {
this.target.appendChild(textarea)
}
/**
* Re-renders the view
* @override
*/
invalidated() {
this.render()
}

View File

@@ -8,29 +8,50 @@ type Registry = {
const registryIdLength = 12
const dec2hex = (dec: number) => ('0' + dec.toString(16)).substr(-2)
/**
* Helper function to generate a random ID
*/
export function getId() {
var arr = new Uint8Array((registryIdLength || 40) / 2)
window.crypto.getRandomValues(arr)
return Array.from(arr, dec2hex).join('')
}
/**
* DOM representation view of the model.
*/
export class TreeView implements ModelListener {
model: DataModel
target: HTMLElement
registry: Registry = {}
/**
* @param model data model this view represents and listens to
* @param target DOM element to render the view
*/
constructor(model: DataModel, target: HTMLElement) {
this.model = model
this.target = target
model.addListener(this)
}
/**
* Registers a callback and gives an ID
* @param callback function that is called when the element is mounted
* @returns the ID that should be applied to the data-id attribute
*/
register(callback: (el: Element) => void): string {
const id = getId()
this.registry[id] = callback
return id
}
/**
* Registers an event and gives an ID
* @param type event type
* @param callback function that is called when the event is fired
* @returns the ID that should be applied to the data-id attribute
*/
registerEvent(type: string, callback: (el: Element) => void): string {
return this.register(el => {
el.addEventListener(type, evt => {
@@ -40,10 +61,20 @@ export class TreeView implements ModelListener {
})
}
/**
* Registers a change event and gives an ID
* @param callback function that is called when the event is fired
* @returns the ID that should be applied to the data-id attribute
*/
registerChange(callback: (el: Element) => void): string {
return this.registerEvent('change', callback)
}
/**
* Registers a click event and gives an ID
* @param callback function that is called when the event is fired
* @returns the ID that should be applied to the data-id attribute
*/
registerClick(callback: (el: Element) => void): string {
return this.registerEvent('click', callback)
}
@@ -57,6 +88,10 @@ export class TreeView implements ModelListener {
}
}
/**
* Re-renders the view
* @override
*/
invalidated(model: DataModel) {
this.render()
}