From 3120058c36e7a0589dbf95001a3101d0cb90582a Mon Sep 17 00:00:00 2001 From: Alejandro Gallardo Escobar Date: Fri, 12 Jul 2019 14:00:24 +0200 Subject: [PATCH] Added item selection and a WIP version of the edition form --- .../javascript/pandora_visual_console.js | 56 ++++- visual_console_client/src/Form.ts | 205 ++++++++++++++++++ visual_console_client/src/Item.ts | 121 ++++++++++- visual_console_client/src/VisualConsole.ts | 103 ++++++++- visual_console_client/src/index.ts | 5 + visual_console_client/src/lib/index.ts | 16 +- visual_console_client/src/lib/types.ts | 3 + visual_console_client/src/main.css | 7 + 8 files changed, 487 insertions(+), 29 deletions(-) create mode 100644 visual_console_client/src/Form.ts diff --git a/pandora_console/include/javascript/pandora_visual_console.js b/pandora_console/include/javascript/pandora_visual_console.js index ad4c841b33..b515ab01cb 100755 --- a/pandora_console/include/javascript/pandora_visual_console.js +++ b/pandora_console/include/javascript/pandora_visual_console.js @@ -124,19 +124,57 @@ function createVisualConsole( visualConsole = new VisualConsole(container, props, items); // VC Item clicked. visualConsole.onItemClick(function(e) { - // Override the link to another VC if it isn't on remote console. - if ( - e.data && - e.data.linkedLayoutId != null && - e.data.linkedLayoutId > 0 && - e.data.link != null && - e.data.link.length > 0 && - (e.data.linkedLayoutAgentId == null || e.data.linkedLayoutAgentId === 0) + var data = e.item.props || {}; + var meta = e.item.meta || {}; + + if (meta.editMode) { + // Item selection. + if (meta.isSelected) { + visualConsole.unselectItem(data.id); + } else { + // Unselect the rest of the elements if the + visualConsole.selectItem(data.id, !e.nativeEvent.metaKey); + } + } else if ( + !meta.editMode && + data.linkedLayoutId != null && + data.linkedLayoutId > 0 && + data.link != null && + data.link.length > 0 && + (data.linkedLayoutAgentId == null || data.linkedLayoutAgentId === 0) ) { + // Override the link to another VC if it isn't on remote console. // Stop the current link behavior. e.nativeEvent.preventDefault(); // Fetch and update the old VC with the new. - updateVisualConsole(e.data.linkedLayoutId, updateInterval); + updateVisualConsole(data.linkedLayoutId, updateInterval); + } + }); + // VC Item double clicked. + visualConsole.onItemDblClick(function(e) { + e.nativeEvent.preventDefault(); + e.nativeEvent.stopPropagation(); + + var item = e.item || {}; + var props = item.props || {}; + var meta = item.meta || {}; + + if (meta.editMode) { + // Item selection. + visualConsole.selectItem(props.id, true); + + var formContainer = item.getFormContainer(); + var formElement = formContainer.getFormElement(); + formContainer.onSubmit(function(e) { + // TODO: Send the update. + console.log("Form submit", e.data); + $(formElement).dialog("close"); + }); + + $(formElement).dialog({ + title: formContainer.title + }); + // TODO: Add submit and reset button. } }); // VC Item moved. diff --git a/visual_console_client/src/Form.ts b/visual_console_client/src/Form.ts new file mode 100644 index 0000000000..e0120410cd --- /dev/null +++ b/visual_console_client/src/Form.ts @@ -0,0 +1,205 @@ +import TypedEvent, { Listener, Disposable } from "./lib/TypedEvent"; +import { AnyObject } from "./lib/types"; + +// TODO: Document +export abstract class InputGroup { + private _name: string = ""; + private _element?: HTMLElement; + public initialData: Data; + protected currentData: Partial = {}; + + public constructor(name: string, initialData: Data) { + this.name = name; + this.initialData = initialData; + } + + public set name(name: string) { + if (name.length === 0) throw new RangeError("empty name"); + this._name = name; + } + + public get name(): string { + return this._name; + } + + public get data(): Partial { + return { ...this.currentData }; + } + + public get element(): HTMLElement { + if (this._element == null) { + const element = document.createElement("div"); + element.className = `input-group input-group-${this.name}`; + + const content = this.createContent(); + + if (content instanceof Array) { + content.forEach(element.appendChild); + } else { + element.appendChild(content); + } + + this._element = element; + } + + return this._element; + } + + public reset(): void { + this.currentData = {}; + } + + protected updateData(data: Partial): void { + this.currentData = { + ...this.currentData, + ...data + }; + // TODO: Update item. + } + + protected abstract createContent(): HTMLElement | HTMLElement[]; + + // public abstract get isValid(): boolean; +} + +export interface SubmitFormEvent { + nativeEvent: Event; + data: AnyObject; +} + +// TODO: Document +export class FormContainer { + public readonly title: string; + private inputGroupsByName: { [name: string]: InputGroup } = {}; + private enabledInputGroupNames: string[] = []; + // Event manager for submit events. + private readonly submitEventManager = new TypedEvent(); + + public constructor( + title: string, + inputGroups: InputGroup[] = [], + enabledInputGroups: string[] = [] + ) { + this.title = title; + + if (inputGroups.length > 0) { + this.inputGroupsByName = inputGroups.reduce((prevVal, inputGroup) => { + prevVal[inputGroup.name] = inputGroup; + return prevVal; + }, this.inputGroupsByName); + } + + if (enabledInputGroups.length > 0) { + this.enabledInputGroupNames = [ + ...this.enabledInputGroupNames, + ...enabledInputGroups.filter( + name => this.inputGroupsByName[name] != null + ) + ]; + } + } + + public getInputGroup(inputGroupName: string): InputGroup | null { + return this.inputGroupsByName[inputGroupName] || null; + } + + public addInputGroup( + inputGroup: InputGroup, + index: number | null = null + ): FormContainer { + this.inputGroupsByName[inputGroup.name] = inputGroup; + + // Remove the current stored name if exist. + this.enabledInputGroupNames = this.enabledInputGroupNames.filter( + name => name === inputGroup.name + ); + + if (index !== null) { + if (index <= 0) { + this.enabledInputGroupNames = [ + inputGroup.name, + ...this.enabledInputGroupNames + ]; + } else if (index >= this.enabledInputGroupNames.length) { + this.enabledInputGroupNames = [ + ...this.enabledInputGroupNames, + inputGroup.name + ]; + } else { + this.enabledInputGroupNames = [ + // part of the array before the specified index + ...this.enabledInputGroupNames.slice(0, index), + // inserted item + inputGroup.name, + // part of the array after the specified index + ...this.enabledInputGroupNames.slice(index) + ]; + } + } else { + this.enabledInputGroupNames = [ + ...this.enabledInputGroupNames, + inputGroup.name + ]; + } + + return this; + } + + public removeInputGroup(inputGroupName: string): FormContainer { + // delete this.inputGroupsByName[inputGroupName]; + // Remove the current stored name. + this.enabledInputGroupNames = this.enabledInputGroupNames.filter( + name => name === inputGroupName + ); + + return this; + } + + public getFormElement(): HTMLFormElement { + const form = document.createElement("form"); + form.addEventListener("submit", e => { + e.preventDefault(); + this.submitEventManager.emit({ + nativeEvent: e, + data: this.enabledInputGroupNames.reduce((data, name) => { + if (this.inputGroupsByName[name]) { + data = { + ...data, + ...this.inputGroupsByName[name].data + }; + } + return data; + }, {}) + }); + }); + + this.enabledInputGroupNames.forEach(name => { + if (this.inputGroupsByName[name]) { + form.appendChild(this.inputGroupsByName[name].element); + } + }); + + return form; + } + + public reset(): void { + this.enabledInputGroupNames.forEach(name => { + if (this.inputGroupsByName[name]) { + this.inputGroupsByName[name].reset(); + } + }); + } + + // public get isValid(): boolean { + // for (let i = 0; i < this.enabledInputGroupNames.length; i++) { + // const inputGroup = this.inputGroupsByName[this.enabledInputGroupNames[i]]; + // if (inputGroup && !inputGroup.isValid) return false; + // } + + // return true; + // } + + public onSubmit(listener: Listener): Disposable { + return this.submitEventManager.on(listener); + } +} diff --git a/visual_console_client/src/Item.ts b/visual_console_client/src/Item.ts index 9d00060311..caf2c139d7 100644 --- a/visual_console_client/src/Item.ts +++ b/visual_console_client/src/Item.ts @@ -16,9 +16,11 @@ import { humanTime, addMovementListener, debounce, - addResizementListener + addResizementListener, + t } from "./lib"; import TypedEvent, { Listener, Disposable } from "./lib/TypedEvent"; +import { FormContainer, InputGroup } from "./Form"; // Enum: https://www.typescriptlang.org/docs/handbook/enums.html. export const enum ItemType { @@ -58,10 +60,8 @@ export interface ItemProps extends Position, Size { aclGroupId: number | null; } -// FIXME: Fix type compatibility. -export interface ItemClickEvent { - // data: Props; - data: AnyObject; +export interface ItemClickEvent { + item: VisualConsoleItem; nativeEvent: Event; } @@ -83,6 +83,41 @@ export interface ItemResizedEvent { newSize: Size; } +// TODO: Document +class PositionInputGroup extends InputGroup { + protected createContent(): HTMLElement | HTMLElement[] { + const positionLabel = document.createElement("label"); + positionLabel.textContent = t("Position"); + + const positionInputX = document.createElement("input"); + positionInputX.type = "number"; + positionInputX.min = "0"; + positionInputX.required = true; + positionInputX.value = `${this.currentData.x || this.initialData.x || 0}`; + positionInputX.addEventListener("change", e => + this.updateData({ + x: parseIntOr((e.target as HTMLInputElement).value, 0) + }) + ); + + const positionInputY = document.createElement("input"); + positionInputY.type = "number"; + positionInputY.min = "0"; + positionInputY.required = true; + positionInputY.value = `${this.currentData.y || this.initialData.y || 0}`; + positionInputY.addEventListener("change", e => + this.updateData({ + y: parseIntOr((e.target as HTMLInputElement).value, 0) + }) + ); + + positionLabel.appendChild(positionInputX); + positionLabel.appendChild(positionInputY); + + return positionLabel; + } +} + /** * Extract a valid enum value from a raw label positi9on value. * @param labelPosition Raw value. @@ -147,7 +182,9 @@ abstract class VisualConsoleItem { // Reference to the DOM element which will contain the view of the item which extends this class. protected childElementRef: HTMLElement = document.createElement("div"); // Event manager for click events. - private readonly clickEventManager = new TypedEvent>(); + private readonly clickEventManager = new TypedEvent(); + // Event manager for double click events. + private readonly dblClickEventManager = new TypedEvent(); // Event manager for moved events. private readonly movedEventManager = new TypedEvent(); // Event manager for resized events. @@ -164,6 +201,10 @@ abstract class VisualConsoleItem { private debouncedMovementSave = debounce( 500, // ms. (x: Position["x"], y: Position["y"]) => { + // Update the metadata information. + // Don't use the .meta property cause we don't need DOM updates. + this._metadata.isBeingMoved = false; + const prevPosition = { x: this.props.x, y: this.props.y @@ -197,6 +238,9 @@ abstract class VisualConsoleItem { this.removeMovement = addMovementListener( element, (x: Position["x"], y: Position["y"]) => { + // Update the metadata information. + // Don't use the .meta property cause we don't need DOM updates. + this._metadata.isBeingMoved = true; // Move the DOM element. this.moveElement(x, y); // Run the save function. @@ -219,6 +263,10 @@ abstract class VisualConsoleItem { private debouncedResizementSave = debounce( 500, // ms. (width: Size["width"], height: Size["height"]) => { + // Update the metadata information. + // Don't use the .meta property cause we don't need DOM updates. + this._metadata.isBeingResized = false; + const prevSize = { width: this.props.width, height: this.props.height @@ -252,6 +300,10 @@ abstract class VisualConsoleItem { this.removeResizement = addResizementListener( element, (width: Size["width"], height: Size["height"]) => { + // Update the metadata information. + // Don't use the .meta property cause we don't need DOM updates. + this._metadata.isBeingResized = true; + // The label it's outside the item's size, so we need // to get rid of its size to get the real size of the // item's content. @@ -353,13 +405,27 @@ abstract class VisualConsoleItem { box.style.zIndex = this.props.isOnTop ? "2" : "1"; box.style.left = `${this.props.x}px`; box.style.top = `${this.props.y}px`; - // Init the click listener. + + // Init the click listeners. + box.addEventListener("dblclick", e => { + if (!this.meta.isBeingMoved && !this.meta.isBeingResized) { + this.dblClickEventManager.emit({ + item: this, + nativeEvent: e + }); + } + }); box.addEventListener("click", e => { if (this.meta.editMode) { e.preventDefault(); e.stopPropagation(); - } else { - this.clickEventManager.emit({ data: this.props, nativeEvent: e }); + } + + if (!this.meta.isBeingMoved && !this.meta.isBeingResized) { + this.clickEventManager.emit({ + item: this, + nativeEvent: e + }); } }); @@ -377,6 +443,9 @@ abstract class VisualConsoleItem { if (this.meta.isUpdating) { box.classList.add("is-updating"); } + if (this.meta.isSelected) { + box.classList.add("is-selected"); + } return box; } @@ -643,6 +712,13 @@ abstract class VisualConsoleItem { this.elementRef.classList.remove("is-updating"); } } + if (!prevMeta || prevMeta.isSelected !== this.meta.isSelected) { + if (this.meta.isSelected) { + this.elementRef.classList.add("is-selected"); + } else { + this.elementRef.classList.remove("is-selected"); + } + } } /** @@ -801,11 +877,20 @@ abstract class VisualConsoleItem { }; } + // TODO: Document + public getFormContainer(): FormContainer { + return new FormContainer( + t("Item"), + [new PositionInputGroup("position", this.props)], + ["position"] + ); + } + /** * To add an event handler to the click of the linked visual console elements. * @param listener Function which is going to be executed when a linked console is clicked. */ - public onClick(listener: Listener>): Disposable { + public onClick(listener: Listener): Disposable { /* * The '.on' function returns a function which will clean the event * listener when executed. We store all the 'dispose' functions to @@ -817,6 +902,22 @@ abstract class VisualConsoleItem { return disposable; } + /** + * To add an event handler to the double click of the linked visual console elements. + * @param listener Function which is going to be executed when a linked console is double clicked. + */ + public onDblClick(listener: Listener): Disposable { + /* + * The '.on' function returns a function which will clean the event + * listener when executed. We store all the 'dispose' functions to + * call them when the item should be cleared. + */ + const disposable = this.dblClickEventManager.on(listener); + this.disposables.push(disposable); + + return disposable; + } + /** * To add an event handler to the movement of visual console elements. * @param listener Function which is going to be executed when a linked console is moved. diff --git a/visual_console_client/src/VisualConsole.ts b/visual_console_client/src/VisualConsole.ts index f669b22be5..460a0e0188 100644 --- a/visual_console_client/src/VisualConsole.ts +++ b/visual_console_client/src/VisualConsole.ts @@ -203,9 +203,9 @@ export default class VisualConsole { [key: string]: Line; } = {}; // Event manager for click events. - private readonly clickEventManager = new TypedEvent< - ItemClickEvent - >(); + private readonly clickEventManager = new TypedEvent(); + // Event manager for double click events. + private readonly dblClickEventManager = new TypedEvent(); // Event manager for move events. private readonly movedEventManager = new TypedEvent(); // Event manager for resize events. @@ -217,11 +217,20 @@ export default class VisualConsole { * React to a click on an element. * @param e Event object. */ - private handleElementClick: (e: ItemClickEvent) => void = e => { + private handleElementClick: (e: ItemClickEvent) => void = e => { this.clickEventManager.emit(e); // console.log(`Clicked element #${e.data.id}`, e); }; + /** + * React to a double click on an element. + * @param e Event object. + */ + private handleElementDblClick: (e: ItemClickEvent) => void = e => { + this.dblClickEventManager.emit(e); + // console.log(`Double clicked element #${e.data.id}`, e); + }; + /** * React to a movement on an element. * @param e Event object. @@ -308,6 +317,7 @@ export default class VisualConsole { this.elementIds.push(itemInstance.props.id); // Item event handlers. itemInstance.onClick(this.handleElementClick); + itemInstance.onDblClick(this.handleElementDblClick); itemInstance.onMoved(this.handleElementMovement); itemInstance.onResized(this.handleElementResizement); itemInstance.onRemove(this.handleElementRemove); @@ -685,9 +695,7 @@ export default class VisualConsole { * Add an event handler to the click of the linked visual console elements. * @param listener Function which is going to be executed when a linked console is clicked. */ - public onItemClick( - listener: Listener> - ): Disposable { + public onItemClick(listener: Listener): Disposable { /* * The '.on' function returns a function which will clean the event * listener when executed. We store all the 'dispose' functions to @@ -699,6 +707,22 @@ export default class VisualConsole { return disposable; } + /** + * Add an event handler to the double click of the linked visual console elements. + * @param listener Function which is going to be executed when a linked console is double clicked. + */ + public onItemDblClick(listener: Listener): Disposable { + /* + * The '.on' function returns a function which will clean the event + * listener when executed. We store all the 'dispose' functions to + * call them when the item should be cleared. + */ + const disposable = this.dblClickEventManager.on(listener); + this.disposables.push(disposable); + + return disposable; + } + /** * Add an event handler to the movement of the visual console elements. * @param listener Function which is going to be executed when a linked console is moved. @@ -750,4 +774,69 @@ export default class VisualConsole { }); this.containerRef.classList.remove("is-editing"); } + + /** + * Select an item. + * @param itemId Item Id. + * @param unique To remove the selection of other items or not. + */ + public selectItem(itemId: number, unique: boolean = false): void { + if (unique) { + this.elementIds.forEach(currentItemId => { + const meta = this.elementsById[currentItemId].meta; + + if (currentItemId !== itemId && meta.isSelected) { + this.elementsById[currentItemId].meta = { + ...meta, + isSelected: false + }; + } else if (currentItemId === itemId && !meta.isSelected) { + this.elementsById[currentItemId].meta = { + ...meta, + isSelected: true + }; + } + }); + } else if (this.elementsById[itemId]) { + this.elementsById[itemId].meta = { + ...this.elementsById[itemId].meta, + isSelected: true + }; + } + } + + /** + * Unselect an item. + * @param itemId Item Id. + */ + public unselectItem(itemId: number): void { + if (this.elementsById[itemId]) { + const meta = this.elementsById[itemId].meta; + + if (meta.isSelected) { + this.elementsById[itemId].meta = { + ...meta, + isSelected: false + }; + } + } + } + + /** + * Unselect all items. + */ + public unselectItems(): void { + this.elementIds.forEach(itemId => { + if (this.elementsById[itemId]) { + const meta = this.elementsById[itemId].meta; + + if (meta.isSelected) { + this.elementsById[itemId].meta = { + ...meta, + isSelected: false + }; + } + } + }); + } } diff --git a/visual_console_client/src/index.ts b/visual_console_client/src/index.ts index 8a414471a0..922d22033b 100644 --- a/visual_console_client/src/index.ts +++ b/visual_console_client/src/index.ts @@ -7,12 +7,17 @@ import "./main.css"; // CSS import. import VisualConsole from "./VisualConsole"; +import * as Form from "./Form"; import AsyncTaskManager from "./lib/AsyncTaskManager"; // Export the VisualConsole class to the global object. // eslint-disable-next-line (window as any).VisualConsole = VisualConsole; +// Export the VisualConsole's Form classes to the global object. +// eslint-disable-next-line +(window as any).VisualConsole.Form = Form; + // Export the AsyncTaskManager class to the global object. // eslint-disable-next-line (window as any).AsyncTaskManager = AsyncTaskManager; diff --git a/visual_console_client/src/lib/index.ts b/visual_console_client/src/lib/index.ts index 1b85dd8003..7d69d948e4 100644 --- a/visual_console_client/src/lib/index.ts +++ b/visual_console_client/src/lib/index.ts @@ -284,7 +284,10 @@ export function itemMetaDecoder(data: UnknownObject): ItemMeta | never { editMode: parseBoolean(data.editMode), isFromCache: parseBoolean(data.isFromCache), isFetching: false, - isUpdating: false + isUpdating: false, + isBeingMoved: false, + isBeingResized: false, + isSelected: false }; } @@ -428,14 +431,17 @@ function getOffset(el: HTMLElement | null) { * * @param element Element to move. * @param onMoved Function to execute when the element moves. + * @param altContainer Alternative element to contain the moved element. * * @return A function which will clean the event handlers when executed. */ export function addMovementListener( element: HTMLElement, - onMoved: (x: Position["x"], y: Position["y"]) => void + onMoved: (x: Position["x"], y: Position["y"]) => void, + altContainer?: HTMLElement ): Function { - const container = element.parentElement as HTMLElement; + const container = altContainer || (element.parentElement as HTMLElement); + // Store the initial draggable state. const isDraggable = element.draggable; // Init the coordinates. @@ -747,3 +753,7 @@ export function addResizementListener( handleEnd(); }; } + +export function t(text: string): string { + return text; +} diff --git a/visual_console_client/src/lib/types.ts b/visual_console_client/src/lib/types.ts index 97f6cb622d..63b64efabd 100644 --- a/visual_console_client/src/lib/types.ts +++ b/visual_console_client/src/lib/types.ts @@ -56,5 +56,8 @@ export interface ItemMeta { isFromCache: boolean; isFetching: boolean; isUpdating: boolean; + isSelected: boolean; + isBeingMoved: boolean; + isBeingResized: boolean; editMode: boolean; } diff --git a/visual_console_client/src/main.css b/visual_console_client/src/main.css index c69a486c8f..b0d7a16295 100644 --- a/visual_console_client/src/main.css +++ b/visual_console_client/src/main.css @@ -32,3 +32,10 @@ background: url(./resize-handle.svg); cursor: se-resize; } + +.visual-console-item.is-editing.is-selected { + border: 2px dashed #2b2b2b; + transform: translateX(-2px) translateY(-2px); + cursor: move; + user-select: none; +}