iobroker.smartcontrol
Version:
Control devices smarter: by grouping, including triggers like motion, opening window, etc. and set target devices accordingly
1,084 lines (906 loc) • 63.7 kB
JavaScript
/* eslint-disable no-irregular-whitespace */
/* eslint-disable-next-line no-undef */
/* eslint-env jquery, browser */ // https://eslint.org/docs/user-guide/configuring#specifying-environments
/* global sendTo, getEnums, common, systemLang, socket, values2table, table2values, M, _, instance */ // for eslint
/**
* List of some global constants
*
* systemLang - 'en', 'de', 'ru', ect. // iobroker.admin/www/js/adapter-settings.js
*/
/**
* ioBroker function explanation
* onChange(boolean) - if set true, grayed out save button turns to blue and being activated, and vice versa if false
* _(string) - the provided translation key will be translated into ioBroker's admin language (words.js)
*
*/
const adapterNamespace = `smartcontrol.${instance}`;
/**
* Basic Constants
*/
// ++++++ Define option tables ++++++
const tableIds = [
'tableTargetDevices',
'tableTargetEnums',
'tableTargetURLs',
'tableConditions',
'tableTriggerMotion',
'tableTriggerDevices',
'tableTriggerTimes',
'tableZones',
];
const optionTablesSettings = {}; // Table variable holding the table settings array
for (const lpTableId of tableIds) {
optionTablesSettings[lpTableId] = [];
}
/**
* Enums
*/
const enums = {};
/** More Globals, being set once load() is called */
let g_settings; // To have globally the settings available.
const g_zonesTargetsOverwrite = {}; // { 'Hallway': {'Hallway.Light':'new val' 'Hallway.Radio':'Radio XYZ'} }, {'Bath Light': '33%'} }
/************************************************************************
*** This is called by the admin adapter once the settings page loads ***
************************************************************************/
function load (settings, onChange) { /*eslint-disable-line no-unused-vars*/
/**
* 06-October-2020
* Once load() is called, we need to get the enums, before we continue.
* Quickly implemented with callbacks and call load2() once completed.
* TODO: Do this more beautiful and with better performance, like with Promise.all() if supported by current browsers
*/
getTargetEnums('rooms', (res)=> {
if(res) {
enums.rooms = res;
} else {
console.warn(`No room enumerations found, so you cannot select rooms in Targets > Enums`);
}
getTargetEnums('functions', (res)=>{
if(res) {
enums.functions = res;
} else {
console.warn(`No function enumerations found, so you cannot use Targets > Table 'Target Devices: Enums'.`);
}
load2(settings, onChange);
});
});
}
function load2(settings, onChange) {
// Adapter Settings
if (!settings) return;
g_settings = settings;
const adapterVersion = common.version;
$('#tabMain span#adapter-version').html(adapterVersion);
/**
* Apply markdown for documentation through https://github.com/zerodevx/zero-md
*/
// index_m.html: All ids defined in <zero-md> tags, like ['md-start', 'md-targetDevices', ...]
const mdIds = $('zero-md[manual-render]').map((i, el) => el.getAttribute('id')).get(); // https://stackoverflow.com/a/54392415
for (const mdId of mdIds) {
const mdFilePath = $('zero-md#' + mdId).attr('src'); // like 'doc-md/start_en.md'
if (mdFilePath) {
if (systemLang !== 'en') { // English is always required
const newFilePath = mdFilePath.slice(0, -5) + `${systemLang}.md`; // remove last 5 chars 'en.js' and add <lang>.js
if (fileExists(newFilePath)) {
$('zero-md#' + mdId).attr('src', newFilePath); // set new file path src of <zero-md>
} else {
// Fallback is English. We add a note to the HTML
$(`
<p class='translation-required'>
Your current ioBroker language is <strong>${systemLang.toUpperCase()}</strong>, however, the following instructions have not yet been translated into your language, so English is used as fallback.
If you are fluently speaking ${systemLang.toUpperCase()}, please help and translate into your language.
The English file is <a target="_blank" href="https://github.com/iobroker-community-adapters/ioBroker.smartcontrol/blob/master/admin/${mdFilePath}">located on Github</a>: <code>https://github.com/iobroker-community-adapters/ioBroker.smartcontrol/blob/master/admin/${mdFilePath}</code>.
Please translate and provide a Github pull request for adding a new file '${newFilePath}' with your ${systemLang.toUpperCase()} translation. Thank you!
</p>
`).insertBefore('zero-md#' + mdId);
}
}
} else {
console.warn(`load(): mdFilePath for '${mdId}' is undefined, so we use English.`);
}
// Finally, render zero-md - https://github.com/zerodevx/zero-md#public-methods
// We add a slight delay, just in case
setTimeout(() => {
const el = document.querySelector('zero-md#' + mdId);
if(el) el.render();
}, 100);
}
/**
* tableTargetEnums: set enum room and function names to drop down (multiple select) fields
*/
if(enums.rooms) $('#tableTargetEnums *[data-name="enumRooms"]').data('options', enums.rooms.join(';'));
if(enums.functions) $('#tableTargetEnums *[data-name="enumId"]').data('options', enums.functions.join(';'));
/**
* Set tableZones>targetsOverwrite to global variable
*/
for (const lpZonesRow of settings['tableZones']) {
const lpZoneName = lpZonesRow.name;
if(!isLikeEmpty(lpZonesRow['targetsOverwrite'])) {
g_zonesTargetsOverwrite[lpZoneName] = lpZonesRow['targetsOverwrite'];
}
}
// This handles if the save button is clickable or not.
// From Adapter Creator.
$('.value').each(function() {
const $key = $(this);
const id = $key.attr('id');
if (!id) {
console.warn(`Attribute 'id' not found for $('.value')`);
} else {
if ($key.attr('type') === 'checkbox') {
// do not call onChange direct, because onChange could expect some arguments
$key.prop('checked', settings[id])
.on('change', () => onChange());
} else {
// do not call onChange direct, because onChange could expect some arguments
$key.val(settings[id])
.on('change', () => onChange())
.on('keyup', () => onChange());
}
}
});
// load fancytree for target device selection modal dialog
fancytreeLoad('fancytree-select-settings');
// ++++++ For option tables ++++++
for (const lpTableId of tableIds) {
optionTablesSettings[lpTableId] = settings[lpTableId] || [];
}
onChange(false);
// ++++++ For option tables ++++++
// values2table() - see iobroker/node_modules/iobroker.admin/www/js/adapter-settings.js
const tTableIds = tableIds;
tTableIds.push('tableZoneExecution'); // special dialog table
for (const lpTableId of tableIds) {
// x@ts-ignore - Ignore "An argument for 'maxRaw' was not provided."
values2table(lpTableId, optionTablesSettings[lpTableId], onChange, function(){val2tableOnReady(lpTableId);});
}
/**
* We call with values2table onReady parameter
* @param {string} tableId - Table Id, like 'tableTriggerMotion', or blank string '' if nothing shall be executed
*/
function val2tableOnReady(tableId) {
switch (tableId) {
case 'tableTargetDevices':
statePathPopupSelection(tableId,'stateSelectPopupOnState', 'onState');
statePathPopupSelection(tableId,'stateSelectPopupOffState', 'offState');
updateTableButtonIcons(tableId, [{dataButton:'stateSelectPopupOnState', icon:'search'},{dataButton:'stateSelectPopupOffState', icon:'search'}]);
addCopyTableRowSmarter(tableId);
break;
case 'tableTargetEnums':
addCopyTableRowSmarter(tableId);
break;
case 'tableTargetURLs':
addCopyTableRowSmarter(tableId);
break;
case 'tableConditions':
statePathPopupSelection(tableId,'stateSelectPopupConditionState', 'conditionState');
updateTableButtonIcons(tableId, [{dataButton:'stateSelectPopupConditionState', icon:'search'}]);
addCopyTableRowSmarter(tableId);
break;
case 'tableTriggerMotion':
statePathPopupSelection(tableId,'stateSelectPopupMotionState', 'stateId');
statePathPopupSelection(tableId,'stateSelectPopupBriState', 'briStateId');
dialogSelectSettings({tableId:'tableTriggerMotion', triggerDataCmd:'motionSelectLinkedTrigger', targetField:'motionLinkedTrigger', dialogTitle:_('Select linked triggers') });
updateTableButtonIcons(tableId, [{dataButton:'stateSelectPopupMotionState', icon:'search'},{dataButton:'stateSelectPopupBriState', icon:'search'}, {dataButton:'motionSelectLinkedTrigger', icon:'pageview'}]);
addCopyTableRowSmarter(tableId);
break;
case 'tableTriggerDevices':
statePathPopupSelection(tableId,'stateSelectPopupStateId', 'stateId');
updateTableButtonIcons(tableId, [{dataButton:'stateSelectPopupStateId', icon:'search'}]);
addCopyTableRowSmarter(tableId);
otherTriggersShowHideUserStates();
break;
case 'tableTriggerTimes':
dialogSelectSettings({tableId:'tableTriggerTimes', triggerDataCmd:'selectAdditionalConditions', targetField:'additionalConditions', dialogTitle:_('Select additional conditions') });
dialogSelectSettings({tableId:'tableTriggerTimes', triggerDataCmd:'selectNever', targetField:'never', dialogTitle:_(`Select 'never if...'`) });
updateTableButtonIcons(tableId, [{dataButton:'selectAdditionalConditions', icon:'pageview'},{dataButton:'selectNever', icon:'pageview'}]);
addCopyTableRowSmarter(tableId);
break;
case 'tableZones':
dialogSelectSettings({tableId:'tableZones', triggerDataCmd:'selectTriggers', targetField:'triggers', dialogTitle:_('Select triggers') });
dialogSelectSettings({tableId:'tableZones', triggerDataCmd:'selectTargetsMenu', targetField:'targets', dialogTitle:_('Select target devices') });
dialogSelectSettings({tableId:'tableZones', triggerDataCmd:'selectNeverOff', targetField:'neverOff', dialogTitle:_(`Select 'never switch off if...'`) });
dialogConfigureZoneExecution();
updateTableButtonIcons(tableId, [{dataButton:'selectTriggers', icon:'pageview'},{dataButton:'selectTargetsMenu', icon:'pageview'},{dataButton:'selectNeverOff', icon:'pageview'},{dataButton:'configureExecution', icon:'schedule', regularSize:true}]);
addCopyTableRowSmarter(tableId);
addInfo(tableId);
break;
case 'tableZoneExecution':
dialogSelectSettings({tableId:tableId, triggerDataCmd:'selectAdditionalConditions', targetField:'additionalConditions', dialogTitle:_('Select additional conditions') });
dialogSelectSettings({tableId:tableId, triggerDataCmd:'selectNever', targetField:'never', dialogTitle:_(`Select 'never switch on if...'`) });
updateTableButtonIcons(tableId, [{dataButton:'selectAdditionalConditions', icon:'pageview'},{dataButton:'selectNever', icon:'pageview'}]);
break;
default:
break;
}
}
// Enhance Tabs with onTabShow-Function. Source: iQontrol Adapter.
// This allows using JavaScript to perform certain actions as defined in function onTabShow(), since we have
// several tabs in this adapter configuration.
onTabShow('#tabMain');
onTabShow('#tabDevices');
onTabShow('#tabConditions');
onTabShow('#tabTriggers');
onTabShow('#tabZones');
onTabShow('#tabSchedules');
// --
$('ul.tabs li a').on('click', function() {
onTabShow($(this).attr('href'));
});
function onTabShow(tabId){
switch(tabId){
case '#tabMain':
$('.collapsible').collapsible(); // https://materializecss.com/collapsible.html
break;
case '#tabDevices':
$('.collapsible').collapsible(); // https://materializecss.com/collapsible.html
if(enums.rooms) $('#tableTargetEnums *[data-name="enumRooms"]').data('options', enums.rooms.join(';'));
if(enums.functions) $('#tableTargetEnums *[data-name="enumId"]').data('options', enums.functions.join(';'));
values2table('tableTargetEnums', optionTablesSettings['tableTargetEnums'], onChange, function(){val2tableOnReady('tableTargetEnums');});
break;
case '#tabConditions':
$('.collapsible').collapsible(); // https://materializecss.com/collapsible.html
break;
case '#tabTriggers':
$('.collapsible').collapsible(); // https://materializecss.com/collapsible.html
populateTable('tableConditions', 'name', 'tableTriggerTimes', 'additionalConditions');
populateTable('tableConditions', 'name', 'tableTriggerTimes', 'never');
populateTable(['tableTriggerDevices'], ['name'], 'tableTriggerMotion', 'motionLinkedTrigger');
break;
case '#tabZones':
$('.collapsible').collapsible(); // https://materializecss.com/collapsible.html
populateTable(['tableTriggerMotion', 'tableTriggerDevices', 'tableTriggerTimes'], ['name', 'name', 'name'], 'tableZones', 'triggers');
populateTable(['tableTargetDevices', 'tableTargetEnums', 'tableTargetURLs'], ['name', 'name', 'name'], 'tableZones', 'targets');
populateTable('tableConditions', 'name', 'tableZones', 'neverOff');
break;
}
}
function otherTriggersShowHideUserStates() {
const jQueryStrCheckbox = `#tableTriggerDevices input[type="checkbox"][data-name="userState"]`;
$(jQueryStrCheckbox).each(function() {
doOrNot($(this));
});
$(jQueryStrCheckbox).on('change', function() {
doOrNot($(this));
});
function doOrNot($checkbox) {
const index = $checkbox.data('index'); // table row number which was clicked, starting at zero.
const $stateField = $(`#tableTriggerDevices tr[data-index="${index}"] input[data-name="stateId"]`);
if($checkbox.prop('checked') == true) {
//checked
$stateField.prev('div.userstates').remove(); // just in case
$stateField.addClass('input-userstates');
$stateField.before(`<div class="translate userstates">${_('State under')} ${adapterNamespace}.userstates. (${_('will be generated automatically')})</div>`);
} else {
//unchecked
$stateField.removeClass('input-userstates');
$stateField.prev('div.userstates').remove();
}
}
}
/**
* Populate select field
* @param {*} sourceTableIds Id of input table, like "tableTriggerMotion". String or array of strings for multiple tables
* @param {*} sourceFieldIds Id of table line field, from which to get content, like "name". . String or array of strings for multiple fields
* @param {string} targetTableId Target table id, like "tableZones"
* @param {string} targetFieldId Target table line field, like 'Test'
* @param {boolean} [sort] Optional: if true, values will be sorted
* @param {boolean} [fill] Optional: if true, table will be filled. Set to false for tableExecution
*/
function populateTable(sourceTableIds, sourceFieldIds, targetTableId, targetFieldId, sort=false, fill=true) {
// jQuery
const jQ = `#${targetTableId} *[data-name="${targetFieldId}"]`;
if(!Array.isArray(sourceTableIds)) sourceTableIds = [sourceTableIds]; // wrap into array
if(!Array.isArray(sourceFieldIds)) sourceFieldIds = [sourceFieldIds]; // wrap into array
const result = [];
for (let i = 0; i < sourceTableIds.length; i++) {
const configTbl = settings[sourceTableIds[i]] || [];
for (const lpElement of configTbl) {
//if (lpElement['active'] == true) { // check for checkbox "active"
result.push(lpElement[sourceFieldIds[i]]);
//}
}
}
// Create dropdown menu
if(sort) result.sort();
$(jQ).data('options', result.join(';'));
// Fill table
if(fill) values2table(targetTableId, optionTablesSettings[targetTableId], onChange, function(){val2tableOnReady(targetTableId);});
}
const fieldChangeConfig = [
{changedTableId:'tableTargetDevices', targetTableId:'tableZones', targetId:'targets'},
{changedTableId:'tableTargetEnums', targetTableId:'tableZones', targetId:'targets'},
{changedTableId:'tableTargetURLs', targetTableId:'tableZones', targetId:'targets'},
{changedTableId:'tableConditions', targetTableId:'tableTriggerTimes', targetId:'additionalConditions'},
{changedTableId:'tableConditions', targetTableId:'tableTriggerTimes', targetId:'never'},
{changedTableId:'tableConditions', targetTableId:'tableZones', targetId:'neverOff'},
{changedTableId:'tableTriggerMotion', targetTableId:'tableZones', targetId:'triggers'},
{changedTableId:'tableTriggerDevices', targetTableId:'tableZones', targetId:'triggers'},
{changedTableId:'tableTriggerDevices', targetTableId:'tableTriggerMotion', targetId:'motionLinkedTrigger'},
{changedTableId:'tableTriggerTimes', targetTableId:'tableZones', targetId:'triggers'},
{changedTableId:'tableTriggerTimes', targetTableId:'tableTriggerMotion', targetId:'motionLinkedTrigger'},
];
onTableRowNameChanges(fieldChangeConfig);
/**
* Handle all Table field changes of column "name":
* Apply any name change (rename) to according tables using the name
* For getting old field value, see https://stackoverflow.com/a/29118530
* @param {array} fieldChangeConfig - Config array
*/
function onTableRowNameChanges(fieldChangeConfig) {
for (const lpConfig of fieldChangeConfig) {
const jQueryName = `#${lpConfig.changedTableId} input.values-input[data-name="name"]`;
// * Important - we cannot use //$(jQueryName).on('xxx') here. See https://stackoverflow.com/a/41457428
// * It is not recognized if populateTable() is executed for that specific table.
// * So we use $(document).on(), which works well.
$(document).on('focusin', jQueryName, function(){ $(this).data('old-val', $(this).val()); });
//$(jQueryName).on('change', function(){
$(document).on('change',jQueryName, function() {
const previousValue = $(this).data('old-val').trim();
const newValue = $(this).val().trim();
if (previousValue != newValue && newValue.length > 0) {
// We have a field change.
// Now let's change all values in according table and target field
for (let i = 0; i < g_settings[lpConfig.targetTableId].length; i++) {
const lpTargets = g_settings[lpConfig.targetTableId][i][lpConfig.targetId];
if (typeof lpTargets == 'string') {
// Handle string
if (lpTargets.trim() == previousValue.trim()) {
g_settings[lpConfig.targetTableId][i][lpConfig.targetId] = newValue;
}
} else {
// We have an array. Process targets accordingly.
for (let k = 0; k < lpTargets.length; k++) {
if (lpTargets[k].trim() == previousValue.trim()) {
g_settings[lpConfig.targetTableId][i][lpConfig.targetId][k] = newValue;
}
}
}
}
}
});
}
}
/**
* For Table Filter
*/
for (const lpId of tableIds) {
// Initially hide 'clear filter button' since no filter is set at this time
$(`div#${lpId} .table-filter button`).hide();
// Apply the filter
applyTableFilter(lpId);
// Clear filters on "Clear Filters" button click
$(`div#${lpId} .table-filter button`).click(function() {
$(`div#${lpId} .table-filter input`).val(''); // empty field
$(`div#${lpId} table`).find('tr:gt(0)').show(); // show all rows
$(`div#${lpId} .table-filter button`).hide(); // hide button
});
}
/**
* Dialog: Configure Execution of Zone
*/
function dialogConfigureZoneExecution() {
const queryResult = $(`#tableZones a.values-buttons[data-command="configureExecution"]`);
queryResult.on('click', function() {
// Fill drop down (select) fields with additional conditions
populateTable('tableConditions', 'name', 'tableZoneExecution', 'additionalConditions', false, false);
populateTable('tableConditions', 'name', 'tableZoneExecution', 'never', false, false);
// a few variables
const tableZonesObj = table2values('tableZones');
const rowNum = $(this).data('index'); // table row number which was clicked, starting at zero.
//const zoneName = optionTablesSettings['tableZones'][rowNum]['name'];
const zoneName = tableZonesObj[rowNum].name;
const always = (tableZonesObj[rowNum].executeAlways) ? true : false;
const tableSett = (tableZonesObj[rowNum].executionJson) ? JSON.parse(tableZonesObj[rowNum].executionJson) : [];
// Prepare table and checkbox
values2table('tableZoneExecution', tableSett, onChange, function(){val2tableOnReady('tableZoneExecution');});
$('#dialog-configure-zone-execution #executeZoneAlways').prop('checked', always);
// Initialize dialog (modal)
initDialog('dialog-configure-zone-execution', dialogOkClose);
// Hide detailed config if "Execute always" checkbox is checked
const $jQueryRes = $('#dialog-configure-zone-execution #executeZoneAlways');
if (always) {
$('#dialog-configure-zone-execution .show-if-always').show();
$('#dialog-configure-zone-execution .hide-if-always').hide();
} else {
$('#dialog-configure-zone-execution .show-if-always').hide();
$('#dialog-configure-zone-execution .hide-if-always').show();
}
$jQueryRes.change(function(){
if(this.checked) {
$('#dialog-configure-zone-execution .hide-if-always').fadeOut('slow');
setTimeout(()=> { $('#dialog-configure-zone-execution .show-if-always').fadeIn('slow'); }, 500);
} else {
$('#dialog-configure-zone-execution .hide-if-always').fadeIn('slow');
$('#dialog-configure-zone-execution .show-if-always').hide();
}
});
// Add Zone Name to title
$('#dialog-configure-zone-execution span.zone-name').text(zoneName);
// Open dialog
$('#dialog-configure-zone-execution').modal('open');
// Called once user clicked "Ok" in the dialog
function dialogOkClose() {
const tableResult = table2values('tableZoneExecution');
tableZonesObj[rowNum].executeAlways = $('#dialog-configure-zone-execution #executeZoneAlways').prop('checked');
tableZonesObj[rowNum].executionJson = JSON.stringify(tableResult);
values2table('tableZones', tableZonesObj, onChange, function(){val2tableOnReady('tableZones');});
}
});
}
/**
* info button for textual explanation of the object
*/
function addInfo(tableId) {
// set the icon
updateTableButtonIcons(tableId, [{dataButton:'info', icon:'live_help'}]);
// Apply info editor
$(`#${tableId} table tbody td a.values-buttons[data-command="info"]`).on('click', function() {
const rowNum = $(this).data('index'); // table row number which was clicked, starting at zero.
const tableArr = table2values(tableId);
const actInfoText = tableArr[rowNum].infoText;
$('#dialog-edit-info #infoText').prop('value', actInfoText);
// Initialize dialog (modal)
initDialog('dialog-edit-info', dialogOkClose);
// Add Name to title
$('#dialog-edit-info span.info-name').text(tableArr[rowNum].name);
// Open dialog
$('#dialog-edit-info').modal('open');
// Called once user clicked "Ok" in the dialog
function dialogOkClose() {
onChange && onChange();
tableArr[rowNum].infoText = $('#dialog-edit-info #infoText').prop('value');
values2table(tableId, tableArr, onChange, function(){val2tableOnReady(tableId);});
}
});
}
/**
* We replace "copy table row" command from node_modules/iobroker.admin/www/js/adapter-settings.js
* Reason: rename name functionality does not work any longer if name keeps the same, therefore
* we add '_Copy' to name field of copied row.
* @param {string} tableId - Table ID, like "tableTargetDevices"
*/
function addCopyTableRowSmarter(tableId) {
// Replace icon, reason: If 'data-buttons' in table th is not set to a known keyword (like 'edit'), it uses 'add' as materialize icon.
updateTableButtonIcons(tableId, [{dataButton:'copy_smart', icon:'content_copy'}]);
// Apply copy
$(`#${tableId} table tbody td a.values-buttons[data-command="copy_smart"]`).on('click', function() {
const rowNum = $(this).data('index'); // table row number which was clicked, starting at zero.
const tableArr = table2values(tableId);
const elem = {... tableArr[rowNum] }; // Copy, so not referencing
if (elem.name) elem.name = elem.name + '_Copy';
// Add copied row below current row, of which the copy button was clicked
//tableArr.splice(rowNum+1, 0, elem);
tableArr.push(elem); // push would add it at the end
onChange && onChange();
g_settings[tableId] = tableArr; // ? Seems to be important: without this, onTableRowNameChanges() will not work
setTimeout(()=> {
// Timeout is set in adapter-settings.js for copy - most likely for very good reasons, so we do as well.
values2table(tableId, tableArr, onChange, function(){val2tableOnReady(tableId);});
}, 100);
// Activate save button
//onChange(true);
});
}
/**
* Dialog: Select Settings (like Triggers or Target Devices)
* @param {object} given - like: {tableId:'tableZones', triggerDataCmd:'selectTargetsMenu', targetField:'targets', dialogTitle:'some title' }
*/
function dialogSelectSettings(given) {
const tableId = given.tableId;
const triggerDataCmd = given.triggerDataCmd;
const targetField = given.targetField;
const dialogTitle = given.dialogTitle;
const queryResult = $(`#${tableId} a.values-buttons[data-command="${triggerDataCmd}"]`);
queryResult.on('click', function() {
// A few variables
let anyChange = false; // true if anything changed, i.e. either checkbox (de-)selected, or overwrite option val changed
const rowNum = $(this).data('index'); // table row number which was clicked, starting at zero. data-index is also the row number starting at zero
const dropDownAllOptions = getSelectOptions(`#${tableId} .values-input[data-name="${targetField}"][data-index="${rowNum}"]`, true);
const dropDownSelectionArray = getSelectOptions(`#${tableId} .values-input[data-name="${targetField}"][data-index="${rowNum}"]`, false);
const editTargetVals = (triggerDataCmd == 'selectTargetsMenu') ? true : false;
// Set modal title
$('#dialog-select-settings>.modal-content>.row>.col>h6.title').text(dialogTitle);
// Initialize dialog (modal)
initDialog('dialog-select-settings', dialogOkClose);
// Hide explanation in dialog if editTargetVals is false
if (!editTargetVals) $('#dialog-select-settings #fancy-explanation').hide();
// Set current settings as source into FancyTree
// If Target Device Selection, we set according zoneName
const zoneName = (editTargetVals) ? (g_settings.tableZones[rowNum].name) : undefined; // like "Relax Area"
$('#fancytree-select-settings').fancytree('option', 'source', convertToFancySource(dropDownAllOptions, dropDownSelectionArray, zoneName));
/**
* Sort nodes
*/
// Folders first. To deactivate folders first: set variable "cmp" to null to deactivate. // https://stackoverflow.com/a/22638802
const cmp=function(a, b) {
const x = (a.isFolder() ? '0' : '1') + a.title.toLowerCase();
const y = (b.isFolder() ? '0' : '1') + b.title.toLowerCase();
return x === y ? 0 : x > y ? 1 : -1;
};
const node = $.ui.fancytree.getTree('#fancytree-select-settings').getRootNode();
node.sortChildren(cmp, true);
// Open dialog
$('#dialog-select-settings').modal('open');
// Called once user clicked "Ok" in the dialog
function dialogOkClose() {
const tree = $.ui.fancytree.getTree('#fancytree-select-settings');
const allFancyNodes = tree.getRootNode().findAll('');
const selectedFancyNodes = tree.getSelectedNodes();
const zoneName = g_settings['tableZones'][rowNum]['name']; // Current Zone Name
for (const lpNode of allFancyNodes) {
if(!lpNode.children) { // We just need end nodes
// If title has {xxx} at the end, we retrieve xxx
const matches = lpNode.title.match(/{(.+)}$/);
if (matches && matches[1] && matches[1].length > 0) {
if (isLikeEmpty(g_zonesTargetsOverwrite) || isLikeEmpty(g_zonesTargetsOverwrite[zoneName])) {
g_zonesTargetsOverwrite[zoneName] = {}; // Add key with empty object
}
// Add to global variable, e.g. '{'Bath.Mirror.Light':'20%'}'
if ( isLikeEmpty(g_zonesTargetsOverwrite[zoneName][lpNode.key])
|| ( !isLikeEmpty(g_zonesTargetsOverwrite[zoneName][lpNode.key])
&& g_zonesTargetsOverwrite[zoneName][lpNode.key] != matches[1] ) )
{
g_zonesTargetsOverwrite[zoneName][lpNode.key] = matches[1];
anyChange = true;
}
} else {
// Remove from global variable
if (!isLikeEmpty(g_zonesTargetsOverwrite)
&& !isLikeEmpty(g_zonesTargetsOverwrite[zoneName])
&& !isLikeEmpty(g_zonesTargetsOverwrite[zoneName][lpNode.key]))
{
delete g_zonesTargetsOverwrite[zoneName][lpNode.key];
anyChange = true;
}
}
}
}
const selectedKeys = [];
for (const selectedNode of selectedFancyNodes) {
selectedKeys.push(selectedNode.key);
}
// check if selected nodes actually changed
if (!arraysEqual(dropDownSelectionArray, selectedKeys)) anyChange = true;
/**
* Finally: Set to option tables, also to ensure materialize select field is being updated, save button is available, etc.
*/
if (anyChange) {
// Option tables
if (tableId == 'tableZoneExecution') {
const tableExecObj = table2values(tableId);
tableExecObj[rowNum][targetField] = selectedKeys;
values2table(tableId, tableExecObj, onChange, function(){val2tableOnReady(tableId);});
} else {
optionTablesSettings[tableId][rowNum][targetField] = selectedKeys;
values2table(tableId, optionTablesSettings[tableId], onChange, function(){val2tableOnReady(tableId);});
}
// Activate save button
onChange(true);
}
}
});
}
// From ioBroker Adapter Creator:
// Re-initialize all the Materialize labels on the page if you are dynamically adding inputs.
// @ts-ignore - Property 'updateTextFields' does not exist on type 'typeof M'.ts(2339)
if (M) M.updateTextFields();
} // load
/**
* Save Options - Called by the admin adapter when the user clicks save
* @param {function} callback(settingsObject) - callback function containing the settings object to be saved.
*/
async function save(callback) { /*eslint-disable-line no-unused-vars*/
try {
/**
* Select elements with class=value and build settings object
* (from Adapter Creator)
*/
let obj = {};
$('.value').each(function() {
const $this = $(this);
if ($this.attr('type') === 'checkbox') {
obj[$this.attr('id')] = $this.prop('checked');
} else {
obj[$this.attr('id')] = $this.val();
}
});
// Set Option Table values
for (const tableId of tableIds) {
obj[tableId] = table2values(tableId);
}
// Set g_zonesTargetsOverwrite
// ! Must be after setting the Option Table Values
for (let i = 0; i < obj['tableZones'].length; i++) {
// get g_zonesTargetsOverwrite value for zone row
const overwriteObject = g_zonesTargetsOverwrite[obj['tableZones'][i]['name']]; // Like {'Hallway.Light':'new val' 'Hallway.Radio':'Radio XYZ'}
if (overwriteObject && !isLikeEmpty(overwriteObject)) {
obj['tableZones'][i]['targetsOverwrite'] = overwriteObject;
} else {
delete obj['tableZones'][i]['targetsOverwrite'];
}
}
/*****************************
* Input Validation
* We use sendTo() to use adapter's node.js code for validation.
*****************************/
let verifyConfigResultObj = undefined;
let errors = [];
// Adapter must be alive
const aliveStateObj = await getStateAsync(`system.adapter.${adapterNamespace}.alive`);
if (aliveStateObj == null) throw(`'getStateAsync(${adapterNamespace}.alive)' returned error.`);
if (aliveStateObj && aliveStateObj.val) {
// Adapter is alive, so verify the config.
verifyConfigResultObj = await sendToAsync('verifyConfig', obj);
if (verifyConfigResultObj == null) throw(`'sendToAsync('verifyConfig', obj)' returned null`);
if (verifyConfigResultObj.passed) {
console.debug(`Verifying Config: successfully passed.`);
// Low priority (nice to have) enhancement idea: we may want to also update configuration by refreshing all tabs / tables.
// set the altered object
// obj = objResult.obj;
} else {
errors = verifyConfigResultObj.issues;
console.warn(`Verifying Config: failed!`);
}
} else {
// Adapter is NOT alive
errors = [`Adapter instance ${adapterNamespace} is not running, so we cannot verify the configuration and cannot save your changes. Please turn the adapter instance on. Hint: To not lose your changes, keep this browser window/tab open, open ioBroker in a new browser window/tab, turn the adapter instance on, and try again to save the changes.`];
}
if(errors.length === 0) {
// Finally, save settings by calling callback function and provide the settings object
callback(obj);
} else {
// Errors occurred, so do not save settings but provide dialog with the errors
let errorHtml = '<ol>\n';
for (const errorEntry of errors) {
errorHtml += `<li>${errorEntry}</li>`;
}
errorHtml += '\n</ol>';
// open error dialog
$('#dialog-save-verification #save-errors').html(errorHtml);
$('#dialog-save-verification').modal();
$('#dialog-save-verification').modal('open');
}
} catch (error) {
console.error(`[save()] - ${error}`);
return;
}
}
/**
* Promise Wrapping:
* async sendTo
* Mic - 25-Nov-2020
* @param {string} cmd - Command
* @param {object} obj - Object
* @return {Promise<object|null>}
*/
function sendToAsync(cmd, obj) {
return new Promise((resolve, reject) => {
sendTo(adapterNamespace, cmd, obj, (result) => {
if (result.error) {
console.error('sendToAsync(): ' + result.error);
reject(null);
} else {
resolve(result);
}
});
});
}
/**
* Promise Wrapping:
* async getState
* Mic - 01-Dec-2020
* @param {string} id - state id
* @return {Promise<object|null>} state object, or null if error
*/
function getStateAsync(id) {
return new Promise((resolve, reject) => {
socket.emit('getState', id, (err, stateObj) => {
if (err) {
console.error('getStateAsync(): ' + err);
reject(null);
} else {
resolve(stateObj);
}
});
});
}
/**
* Update Table Button Icons
* If 'data-buttons' in table th is not set to a known keyword (like 'edit'), it uses 'add' as materialize icon.
* We modify this icon by replacing 'add' with an icon of our choice: https://materializecss.com/icons.html
*
* @param {string} tableId - Table ID, like "tableTargetDevices"
* @param {array} iconsConfArr - [{dataButton:'xyz', icon:'search'}, {data-button:'abc', icon:'add', regularSize:true}];
*/
function updateTableButtonIcons(tableId, iconsConfArr) {
for (const lpIconObj of iconsConfArr) {
$(`#${tableId} table tbody td a.values-buttons[data-command="${lpIconObj.dataButton}"] i.material-icons:contains('add')`).each(function() {
const text = $(this).text().replace('add', lpIconObj.icon);
$(this).text(text);
});
if (lpIconObj.regularSize) {
// replace .btn-small with .btn to change icon to regular size
const $query = $(`#${tableId} table tbody td a.values-buttons[data-command="${lpIconObj.dataButton}"].btn-small`);
$query.addClass('btn').removeClass('btn-small');
}
}
}
/*************************************************************
* Table Filter
* Inspired by: https://github.com/mjansma/LiveSearch/livesearch.js
* @param {string} id - like 'tableTargetDevices'
*************************************************************/
function applyTableFilter(id) {
$(`div#${id} .table-filter input`).keyup(function() {
//$(this).keyup(function() {
const table = $('div#' + id + ' table');
//Get all table columns
const children = table.find('td');
const searchString = $(this).val().toLowerCase();
if (searchString.length < 1) {
$(`div#${id} .table-filter button`).hide(); // hide filter button
table.find('tr:gt(0)').show(); // show all if search string is too short
return;
} else {
$(`div#${id} .table-filter button`).show(); // show filter button since we have 2+ chars in filter
//Hide all rows except the table header
table.find('tr:gt(0)').hide();
//Loop through all table columns
children.each(function(index, child){
//If search string matches table column
let checkFor;
if (child.firstChild && child.firstChild.firstChild && child.firstChild.firstChild.value) {
checkFor = child.firstChild.firstChild.value; // we have a drop down
} else if (!checkFor && child.firstChild && child.firstChild.value && child.firstChild.value != '' && child.firstChild.value != 'on') {
checkFor = child.firstChild.value;
}
if (checkFor && checkFor.toLowerCase().indexOf(searchString) != -1) {
$(child).closest('tr').show(); //Show table row
}
});
}
});
}
/**
* From: selectID.js (node_modules/iobroker.admin/www/lib/js/)
* Name "dialog-select-member" is important, because for that exist the CSS classes
* Important to have "admin/img/big-info.png", because this icon will be loaded if no icon found, otherwise we have endless loop
*/
let selectId;
function initSelectId (cb) {
if (selectId) return cb ? cb(selectId) : selectId;
socket.emit('getObjects', function (err, res) {
if (!err && res) {
selectId = $('#dialog-select-member').selectId('init', {
noMultiselect: true,
objects: res,
imgPath: '../../lib/css/fancytree/',
filter: {type: 'state'},
name: 'adapter-select-state',
texts: {
select: _('Select'),
cancel: _('Cancel'),
all: _('All'),
id: _('ID'),
name: _('Name'),
role: _('Role'),
room: _('Room'),
value: _('Value'),
selectid: _('Select state'),
from: _('From'),
lc: _('Last changed'),
ts: _('Time stamp'),
wait: _('Processing...'),
ack: _('Acknowledged'),
selectAll: _('Select all'),
unselectAll: _('Deselect all'),
invertSelection: _('Invert selection')
},
columns: ['image', 'name', 'role', 'room']
});
cb && cb(selectId);
}
});
}
/**
* Opens the dialog for state selection.
* Using selectID.js (node_modules/iobroker.admin/www/lib/js/)
* It monitors a button for a click, and writes the selected state into a field.
*
* @param {string} tableId - The id of the table, like 'tableTargetDevices'
* @param {string} dataCommand - Name of [data-buttons="edit1"] of the <th>. so like 'edit1'.
* If you use multiple buttons per table row, use like 'edit1', 'edit2' for 'data-buttons=' in <th>
* @param {string} targetField - The target field, like 'onState' (of data-name="onState")
*/
function statePathPopupSelection(tableId, dataCommand, targetField) {
const queryResult = $(`#${tableId} a.values-buttons[data-command="${dataCommand}"]`);
queryResult.on('click', function() {
const id = $(this).data('index');
initSelectId(function (sid) {
sid.selectId('show', $(`#${tableId} .values-input[data-name="${targetField}"][data-index="${id}"]`).val(), function (statePath) {
if (statePath) {
// We have a selected state, so let's fill the target field
$(`#${tableId} .values-input[data-name="${targetField}"][data-index="${id}"]`).val(statePath).trigger('change');
}
});
});
});
}
/**
* Initializes a dialog (materialize Modal)
*
* @source iQontrol adapter
*
* @param {string} id - id of Modal, like 'dialog-select-settings'
* @param {*} callback - callback function.
*/
function initDialog(id, callback) {
const $dialog = $('#' + id);
if (!$dialog.data('isInitialized')) {
$dialog.data('isInitialized', true);
$dialog.modal({
dismissible: false
});
$dialog.find('.btn-set').on('click', function () {
const $dialog = $('#' + $(this).data('dialogId'));
const callback = $dialog.data('callback');
if (typeof callback === 'function') callback();
$dialog.data('callback', null);
});
}
$dialog.find('.btn-set').data('dialogId', id);
$dialog.data('callback', callback);
}
/**
* To be called in load() function of index_m.html / index_m.js
* @param {string} fancytreeId - like 'fancytree-select-settings' for #fancytree-select-settings
*/
function fancytreeLoad(fancytreeId) {
$(`#${fancytreeId}`).fancytree({
checkbox: true,
checkboxAutoHide: undefined, // Display check boxes on hover only
extensions: ['filter', 'edit'],
quicksearch: true,
filter: {
autoApply: true, // Re-apply last filter if lazy data is loaded
autoExpand: true, // Expand all branches that contain matches while filtered
counter: true, // Show a badge with number of matching child nodes near parent icons
fuzzy: false, // Match single characters in order, e.g. 'fb' will match 'FooBar'
hideExpandedCounter: true, // Hide counter badge if parent is expanded
hideExpanders: false, // Hide expanders if all child nodes are hidden by filter
highlight: true, // Highlight matches by wrapping inside <mark> tags
leavesOnly: false, // Match end nodes only
nodata: false, // Display a 'no data' status node if result is empty
mode: 'hide' // 'dimm' to gray out unmatched nodes, 'hide' to remove unmatched node instead)
},
edit: {
// Available options with their default:
adjustWidthOfs: 4, // null: don't adjust input size to content
inputCss: { minWidth: '3em' },
triggerStart: ['f2', 'dblclick', 'shift+click', 'mac+enter'],
beforeEdit: function(event, data){
if (data.node.children) return false; // Return false to prevent edit mode for folders (i.e. if nodes having children)
if (!data.node.data.overwriteTargets) return false; // Go out if we do not want to overwrite / set new target values
},
edit: function(event, data){ // Editor opened (available as data.input)
// Some additional HTML/CSS
$('input.fancytree-edit-input').addClass('browser-default');
$('input.fancytree-edit-input').before(`<span>${data.node.key.split('.').pop()} - ${_('overwrite options')}: {</span>`);
$('input.fancytree-edit-input').after(`<span>}</span>`);
const newVal = data.input.val();
const matches = newVal.match(/{(.+)}$/);
if (matches && matches[1] && matches[1].length > 0) {
$('input.fancytree-edit-input').val(matches[1]);
} else {
$('input.fancytree-edit-input').val('');
}
},
save: $.noop,
beforeClose: $.noop,
// We handle all in close.
close: function(event, data) {
const key = data.node.key; // key, like "Bath.Lights.Mirror Light"
const nodeTitle = key.split('.').pop(); // like 'Mirror Light'
const newVal = data.node.title; // the new value which the user entered
if (!newVal || newVal.length