Added item selection and a WIP version of the edition form

This commit is contained in:
Alejandro Gallardo Escobar 2019-07-12 14:00:24 +02:00
parent 8047bf2c87
commit 3120058c36
8 changed files with 487 additions and 29 deletions

View File

@ -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.

View File

@ -0,0 +1,205 @@
import TypedEvent, { Listener, Disposable } from "./lib/TypedEvent";
import { AnyObject } from "./lib/types";
// TODO: Document
export abstract class InputGroup<Data extends {} = {}> {
private _name: string = "";
private _element?: HTMLElement;
public initialData: Data;
protected currentData: Partial<Data> = {};
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<Data> {
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<Data>): 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<SubmitFormEvent>();
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<SubmitFormEvent>): Disposable {
return this.submitEventManager.on(listener);
}
}

View File

@ -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<Props extends ItemProps> {
// data: Props;
data: AnyObject;
export interface ItemClickEvent {
item: VisualConsoleItem<ItemProps>;
nativeEvent: Event;
}
@ -83,6 +83,41 @@ export interface ItemResizedEvent {
newSize: Size;
}
// TODO: Document
class PositionInputGroup extends InputGroup<ItemProps> {
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<Props extends ItemProps> {
// 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<ItemClickEvent<Props>>();
private readonly clickEventManager = new TypedEvent<ItemClickEvent>();
// Event manager for double click events.
private readonly dblClickEventManager = new TypedEvent<ItemClickEvent>();
// Event manager for moved events.
private readonly movedEventManager = new TypedEvent<ItemMovedEvent>();
// Event manager for resized events.
@ -164,6 +201,10 @@ abstract class VisualConsoleItem<Props extends ItemProps> {
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<Props extends ItemProps> {
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<Props extends ItemProps> {
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<Props extends ItemProps> {
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<Props extends ItemProps> {
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<Props extends ItemProps> {
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<Props extends ItemProps> {
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<Props extends ItemProps> {
};
}
// 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<ItemClickEvent<Props>>): Disposable {
public onClick(listener: Listener<ItemClickEvent>): 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<Props extends ItemProps> {
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<ItemClickEvent>): 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.

View File

@ -203,9 +203,9 @@ export default class VisualConsole {
[key: string]: Line;
} = {};
// Event manager for click events.
private readonly clickEventManager = new TypedEvent<
ItemClickEvent<ItemProps>
>();
private readonly clickEventManager = new TypedEvent<ItemClickEvent>();
// Event manager for double click events.
private readonly dblClickEventManager = new TypedEvent<ItemClickEvent>();
// Event manager for move events.
private readonly movedEventManager = new TypedEvent<ItemMovedEvent>();
// 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<ItemProps>) => 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<ItemClickEvent<ItemProps>>
): Disposable {
public onItemClick(listener: Listener<ItemClickEvent>): 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<ItemClickEvent>): 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
};
}
}
});
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -56,5 +56,8 @@ export interface ItemMeta {
isFromCache: boolean;
isFetching: boolean;
isUpdating: boolean;
isSelected: boolean;
isBeingMoved: boolean;
isBeingResized: boolean;
editMode: boolean;
}

View File

@ -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;
}