/**
* Refer to <span color = "yellow">{@link ve.Component}</span> for methods or fields inherited from this Component's parent such as `.options.attributes` or `.element`.
*
* A recursive editor for JavaScript Objects, Arrays, and primitives.
* Layout: [Icon] [Key] : [Value] ... [Delete]
* - Functional binding: <span color=00ffff>veObjectEditor</span>().
*
* ##### Constructor:
* - `arg0_value`: {@link Object}|{@link Array} - The object to edit.
* - `arg1_options`: {@link Object}
* - `.auto_collapse_depth=1`: {@link number} - The depth at which folders start collapsing automatically.
* - `.do_not_allow_deletion=false`: {@link boolean}
* - `.do_not_allow_insertion=false`: {@link boolean}
* - `.do_not_allow_key_change=false`: {@link boolean}
* - `.do_not_allow_type_change=false`: {@link boolean}
* - `.do_not_display_icons=false`: {@link boolean} - Whether to show type icons.
* - `.preserve_structure=false`: {@link boolean} - Whether to only allow changes to primitives, and not to the data structure itself.
*
* ##### Instance:
* - `.hierarchy`: {@link ve.Hierarchy} - The internal hierarchy component.
* - `.v`: {@link Object}|{@link Array}
*
* ##### Methods:
* - <span color=00ffff>{@link ve.ObjectEditor.refresh|refresh}</span>()
*
* @augments ve.Component
* @memberof ve.Component
* @type {ve.ObjectEditor}
*/
ve.ObjectEditor = class extends ve.Component { //[WIP] - Refactor at a later date. Fix documentation.
constructor (arg0_value, arg1_options) {
//Convert from parameters
let value = (arg0_value) ? arg0_value : {};
let options = (arg1_options) ? arg1_options : {};
super(options);
//Initialise options
options.attributes = (options.attributes) ? options.attributes : {};
options.auto_collapse_depth = Math.returnSafeNumber(options.auto_collapse_depth, 1);
//.preserve_structure handling
if (options.preserve_structure) {
options.do_not_allow_deletion = (options.do_not_allow_deletion === undefined) ?
true : options.do_not_allow_deletion;
options.do_not_allow_insertion = (options.do_not_allow_insertion === undefined) ?
true : options.do_not_allow_insertion;
options.do_not_allow_key_change = (options.do_not_allow_key_change === undefined) ?
true : options.do_not_allow_key_change;
options.do_not_allow_type_change = (options.do_not_allow_type_change === undefined) ?
true : options.do_not_allow_type_change;
}
//Declare local instance variables
this.element = document.createElement("div");
this.element.setAttribute("component", "ve-object-editor");
HTML.setAttributesObject(this.element, options.attributes);
this.element.instance = this;
this.value = value;
this.options = options;
this.refresh();
}
/**
* Returns the component value to a JSON object.
* - Accessor of: {@link ve.ObjectEditor}
*
* @alias v
* @memberof ve.Component.ve.ObjectEditor
* @type {Object}
*/
get v () {
//Return statement
return this.value;
}
/**
* Sets the new component value to a JSON object.
* - Accessor of: {@link ve.ObjectEditor}
*
* @alias v
* @memberof ve.Component.ve.ObjectEditor
*
* @param {Object} arg0_value
*/
set v (arg0_value) {
//Convert from parameters
this.value = arg0_value;
//Fire binding
this.refresh();
this.fireFromBinding();
}
_getType (arg0_value) {
//Convert from parameters
let value = arg0_value;
//Return statement
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
_moveArrayItem (arg0_array, arg1_old_index, arg2_new_index) {
//Convert from parameters
let arr = arg0_array;
let old_index = arg1_old_index;
let new_index = arg2_new_index;
//new_index; old_index handling
if (new_index >= arr.length) new_index = arr.length - 1;
if (new_index < 0) new_index = 0;
if (old_index === new_index) return; //Internal guard clause if it is being moved into the same item
let item = arr.splice(old_index, 1)[0];
arr.splice(new_index, 0, item);
//Refresh view and fire to binding
this.refresh();
this.fireToBinding();
}
_openAddModal (arg0_target_obj) {
//Convert from parameters
let target_obj = arg0_target_obj;
//Declare local instance variables
let is_array = Array.isArray(target_obj);
let components_obj = {
info: new ve.HTML((is_array) ? loc("ve.registry.localisation.ObjectEditor_add_array_info") : loc("ve.registry.localisation.ObjectEditor_add_object_info")),
type_select: new ve.Select({
string: { name: loc("ve.registry.localisation.ObjectEditor_type_string"), selected: true },
number: { name: loc("ve.registry.localisation.ObjectEditor_type_number") },
boolean: { name: loc("ve.registry.localisation.ObjectEditor_type_boolean") },
object: { name: loc("ve.registry.localisation.ObjectEditor_type_object") },
array: { name: loc("ve.registry.localisation.ObjectEditor_type_array") },
null: { name: loc("ve.registry.localisation.ObjectEditor_type_null") }
}, {
style: { width: "100%", marginBottom: "var(--cell-padding)" }
}),
confirm_button: new ve.Button(() => {
let selected_type = this.modal_window.components_obj.type_select.v;
let new_key = (is_array) ? target_obj.length : this.modal_window.components_obj.key_name.v;
if (!is_array && !new_key) {
new ve.Toast(loc("ve.registry.localisation.ObjectEditor_toast_enter_property_name"));
return;
}
if (!is_array && target_obj.hasOwnProperty(new_key)) {
new ve.Toast(loc("ve.registry.localisation.ObjectEditor_toast_property_exists", new_key));
return;
}
let new_value = null;
switch (selected_type) {
case "string": new_value = ""; break;
case "number": new_value = 0; break;
case "boolean": new_value = false; break;
case "object": new_value = {}; break;
case "array": new_value = []; break;
case "null": new_value = null; break;
}
if (is_array) target_obj.push(new_value);
else target_obj[new_key] = new_value;
this.modal_window.close();
this.refresh();
this.fireToBinding();
}, { name: loc("ve.registry.localisation.ObjectEditor_button_add_item"), style: { width: "100%" } })
};
if (!is_array)
components_obj.key_name = new ve.Text("", {
placeholder: loc("ve.registry.localisation.ObjectEditor_placeholder_property_name"),
style: { marginBottom: "var(--cell-padding)" }
});
//Open modal_window if possible
if (this.modal_window) this.modal_window.close();
this.modal_window = new ve.Window(components_obj, {
name: (is_array) ? loc("ve.registry.localisation.ObjectEditor_window_add_array_element") : loc("ve.registry.localisation.ObjectEditor_window_add_variable"),
can_rename: false
});
}
_openChangeTypeModal (arg0_target_object, arg1_key) {
//Convert from parameters
let target_obj = arg0_target_object;
let key = arg1_key;
//Declare local instance variables
let current_val = target_obj[key];
let current_type = this._getType(current_val);
let components_obj = {
info: new ve.HTML(loc("ve.registry.localisation.ObjectEditor_change_type_info", key)),
type_select: new ve.Select({
string: { name: loc("ve.registry.localisation.ObjectEditor_type_string"), selected: current_type === "string" },
number: { name: loc("ve.registry.localisation.ObjectEditor_type_number"), selected: current_type === "number" },
boolean: { name: loc("ve.registry.localisation.ObjectEditor_type_boolean"), selected: current_type === "boolean" },
object: { name: loc("ve.registry.localisation.ObjectEditor_type_object"), selected: current_type === "object" },
array: { name: loc("ve.registry.localisation.ObjectEditor_type_array"), selected: current_type === "array" },
null: { name: loc("ve.registry.localisation.ObjectEditor_type_null"), selected: current_type === "null" }
}, {
style: { width: "100%", marginBottom: "var(--cell-padding)" }
}),
confirm_button: new ve.Button(() => {
let selected_type = this.change_type_window.components_obj.type_select.v;
// Don't do anything if type didn't change
if (selected_type === current_type) {
this.change_type_window.close();
return;
}
let new_value = null;
switch (selected_type) {
case "string": new_value = ""; break;
case "number": new_value = 0; break;
case "boolean": new_value = false; break;
case "object": new_value = {}; break;
case "array": new_value = []; break;
case "null": new_value = null; break;
}
target_obj[key] = new_value;
this.change_type_window.close();
this.refresh();
this.fireToBinding();
}, {
name: loc("ve.registry.localisation.ObjectEditor_button_change_type"),
style: { width: "100%" }
})
};
//Open change_type_window if possible
if (this.change_type_window) this.change_type_window.close();
this.change_type_window = new ve.Window(components_obj, {
name: loc("ve.registry.localisation.ObjectEditor_window_change_type"),
can_rename: false,
width: "300px",
height: "auto"
});
}
_renameObjectKey(obj, old_key, new_key) {
if (old_key === new_key) return;
if (obj.hasOwnProperty(new_key)) {
new ve.Toast(loc("ve.registry.localisation.ObjectEditor_toast_key_exists", new_key));
this.refresh();
return;
}
const keys = Object.keys(obj);
const new_obj = {};
keys.forEach(key => {
if (key === old_key) new_obj[new_key] = obj[old_key];
else new_obj[key] = obj[key];
});
Object.keys(obj).forEach(k => delete obj[k]);
Object.assign(obj, new_obj);
this.refresh();
this.fireToBinding();
}
/**
* Generates HTML recursively for the current component.
* - Private method of: {@link ve.ObjectEditor}
*
* @param arg0_current_data
* @param arg1_current_key
* @param arg2_depth
* @param arg3_parent_object
* @returns {*|ve.Component.HierarchyDatatype}
*
* @private
*/
_generateRecursive (arg0_current_data, arg1_current_key, arg2_depth, arg3_parent_object) { //[WIP] - Refactor later
//Convert from parameters
let current_data = arg0_current_data;
let current_key = arg1_current_key;
let depth = arg2_depth;
let parent_object = arg3_parent_object;
//Declare local instance variables
let components_obj = {};
let type = this._getType(current_data);
let is_group = (type === "object" || type === "array");
let parent_is_array = Array.isArray(parent_object);
//1. Icon (Order 0)
if (!this.options.do_not_display_icons) {
let icon_name = "help_outline";
switch(type) {
case "object": icon_name = "data_object"; break;
case "array": icon_name = "data_array"; break;
case "string": icon_name = "short_text"; break;
case "number": icon_name = "123"; break;
case "boolean": icon_name = "toggle_on"; break;
case "null": icon_name = "do_not_disturb_on"; break;
}
let icon_style = {
order: 0,
marginRight: "0.5rem",
opacity: 0.7,
display: "flex",
alignItems: "center",
border: "none",
padding: 0
};
//Changed to ve.Button to allow clicking to change type
if (!this.options.do_not_allow_type_change) {
components_obj.icon = new ve.Button((e) => {
this._openChangeTypeModal(parent_object, current_key);
}, {
name: `<icon>${icon_name}</icon>`,
attributes: {
"data-is-type": true
},
style: icon_style,
tooltip: loc("ve.registry.localisation.ObjectEditor_tooltip_change_type", type)
});
} else {
components_obj.icon = new ve.HTML(`<icon>${icon_name}</icon>`, {
attributes: { "data-is-type": true },
style: icon_style,
tooltip: loc("ve.registry.localisation.ObjectEditor_tooltip_type", type)
})
}
}
//2. Key Input (Order 1)
if (!this.options.do_not_allow_key_change) {
if (parent_is_array) {
components_obj.key_input = new ve.Number(current_key, {
min: 0,
max: parent_object.length - 1,
onuserchange: (new_index) => {
this._moveArrayItem(parent_object, current_key, new_index);
},
style: {
width: "3rem"
}
});
} else {
components_obj.key_input = new ve.Text(current_key, {
onuserchange: (new_key) => {
this._renameObjectKey(parent_object, current_key, new_key);
},
style: {
maxWidth: "6rem"
}
});
}
} else {
components_obj.key_name = new ve.HTML(current_key, {
style: {
maxWidth: "4rem"
}
})
}
//3. Separator (Order 2)
components_obj.separator = new ve.HTML("<b> : </b>", {
style: { opacity: 0.5 }
});
//4. Value Input (Order 3)
if (!is_group) {
if (type === "string") {
components_obj.input = new ve.Text(current_data, {
onuserchange: (v) => { parent_object[current_key] = v; this.fireToBinding(); },
style: { order: 3, flex: "1 1 auto" }
});
} else if (type === "number") {
components_obj.input = new ve.Number(current_data, {
onuserchange: (v) => { parent_object[current_key] = v; this.fireToBinding(); },
style: { order: 3 }
});
} else if (type === "boolean") {
components_obj.input = new ve.Toggle(current_data, {
onuserchange: (v) => { parent_object[current_key] = v; this.fireToBinding(); },
on_name: loc("ve.registry.localisation.ObjectEditor_value_true"),
off_name: loc("ve.registry.localisation.ObjectEditor_value_false"),
style: { order: 3 }
});
} else {
//Null or other types
components_obj.input = new ve.HTML(`<span style="opacity:0.5">${String(current_data)}</span>`, {
style: { order: 3 }
});
}
} else {
//Group Add Button
components_obj.add_child_btn = new ve.Button((e) => {
this._openAddModal(current_data);
}, {
name: "<icon>add</icon>",
limit: () => (!this.options.do_not_allow_insertion),
tooltip: loc("ve.registry.localisation.ObjectEditor_tooltip_add_item"),
style: { order: 3 }
});
}
//5. Delete Button (Order 4)
//This gets marginLeft: auto to push it to the far right edge of the container
components_obj.delete_btn = new ve.Button((e) => {
veConfirm(loc("ve.registry.localisation.ObjectEditor_confirm_delete", current_key), {
special_function: () => {
if (Array.isArray(parent_object)) {
parent_object.splice(current_key, 1);
} else {
delete parent_object[current_key];
}
this.refresh();
this.fireToBinding();
}
});
}, {
name: "<icon>delete</icon>",
limit: () => (!this.options.do_not_allow_deletion),
tooltip: loc("ve.registry.localisation.ObjectEditor_tooltip_delete"),
style: {
order: 4,
color: "var(--color-error)"
}
});
//Handle children
if (is_group)
Object.keys(current_data).forEach((key) => {
let safe_key = key;
if (Array.isArray(current_data)) safe_key = parseInt(key);
components_obj[`child_${key}`] = this._generateRecursive(current_data[key], safe_key, depth + 1, current_data);
});
//Return statement
return new ve.HierarchyDatatype(components_obj, {
name: (typeof current_key === "string") ? current_key : current_key.toString(),
type: is_group ? "group" : "item",
is_collapsed: (depth >= this.options.auto_collapse_depth),
// Metadata for reordering
data_container: parent_object,
data_key: current_key,
data_value: current_data,
style: {
".nst-content": {
display: "flex",
alignItems: "center",
gap: "0px" // Removing gap to rely on element margins
},
'input': { maxWidth: "8rem" },
width: "20rem"
}
});
}
_handleReorder (arg0_v, arg1_e) {
//Convert from parameters
let v = arg0_v;
let e = arg1_e;
let stop_data = e.on_stop_data;
if (!stop_data) return; //Internal guard clause if no event date
//Declare local instance variables
let moved_instance = stop_data.movedNode.instance;
let original_parent_instance = stop_data.originalParentItem?.instance;
let new_parent_instance = stop_data.newParentItem?.instance;
let source_container = (original_parent_instance) ?
original_parent_instance.options.data_value : this.value;
let dest_container = (new_parent_instance) ?
new_parent_instance.options.data_value : this.value;
let key_to_move = moved_instance.options.data_key;
let val_to_move = source_container[key_to_move];
let parent_ol = stop_data.newParentItem ?
stop_data.newParentItem.querySelector("ol") :
this.element.querySelector("ol.ve-hierarchy");
let siblings = Array.from(parent_ol.children).filter(el =>
el.getAttribute("component") === "ve-hierarchy-datatype" && !el.classList.contains("actions-bar")
);
let new_index = siblings.indexOf(stop_data.movedNode);
//Add
if (Array.isArray(dest_container)) {
dest_container.splice(new_index, 0, val_to_move);
} else {
dest_container[key_to_move] = val_to_move;
}
//Remove
if (Array.isArray(source_container)) {
source_container.splice(key_to_move, 1);
} else {
delete source_container[key_to_move];
}
//Refresh and fire to binding
this.refresh();
this.fireToBinding();
}
/**
* Refreshes the given {@link ve.ObjectEditor} to be in sync with the JSON object passed in.
* - Method of: {@link ve.ObjectEditor}
*
* @alias refresh
* @memberof ve.Component.ve.ObjectEditor
*/
refresh () {
//Declare local instance varioables
this.element.innerHTML = "";
let actions_bar = new ve.HierarchyDatatype({
add_root_btn: new ve.Button(() => {
if (this.value === null) this.value = {};
this._openAddModal(this.value);
}, {
name: "<icon>playlist_add</icon>",
tooltip: loc("ve.registry.localisation.ObjectEditor_tooltip_add_variable")
}),
information: new ve.Button(() => {}, {
name: "<icon>info</icon>",
tooltip: loc("ve.registry.localisation.ObjectEditor_tooltip_info")
})
}, {
disabled: true,
attributes: { class: "actions-bar" },
style: {
marginTop: `calc(var(--padding))`,
width: "20rem"
}
});
let hierarchy_obj = (!this.options.do_not_allow_insertion) ?
{ actions_bar: actions_bar } : {};
//Assign objects recursively when drawing UI
if (this.value && typeof this.value === "object")
Object.keys(this.value).forEach((key) => {
let safe_key = key;
if (Array.isArray(this.value)) safe_key = parseInt(key);
hierarchy_obj[`root_${key}`] = this._generateRecursive(this.value[key], safe_key, 0, this.value);
});
this.hierarchy = new ve.Hierarchy(hierarchy_obj, {
disable_searchbar: false,
searchbar_style: { marginBottom: "0" },
onuserchange: (v, e) => this._handleReorder(v, e)
});
this.hierarchy.element.style.height = "100%";
this.hierarchy.element.style.overflowY = "auto";
if (this.owner) this.hierarchy.setOwner(this.owner);
this.element.appendChild(this.hierarchy.element);
}
};
//Functional binding
/**
* @returns {ve.ObjectEditor}
*/
veObjectEditor = function () {
return new ve.ObjectEditor(...arguments);
};