From 06073bfc25a1dc815bcd654d53f30bfd535511bd Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Tue, 14 Dec 2021 20:23:56 -0500 Subject: [PATCH] Added settings page and all the things that go along with it. --- api/cora/api_cache.php | 22 +++ api/cora/api_call.php | 86 ++++++++-- api/profile.php | 48 ++++-- api/thermostat.php | 6 +- api/user.php | 69 +++++++- css/dashboard.css | 51 ++++++ js/beestat/api.js | 18 +- js/beestat/setting.js | 159 +++++++++++++----- .../card/runtime_thermostat_summary.js | 32 ---- js/component/card/settings.js | 145 ++++++++++++++++ js/component/card/temperature_profiles.js | 1 + js/component/header.js | 7 + js/component/input.js | 13 ++ js/component/input/checkbox.js | 85 ++++++++++ js/component/input/text.js | 2 +- js/js.php | 3 + js/layer/settings.js | 51 ++++++ 17 files changed, 691 insertions(+), 107 deletions(-) create mode 100644 js/component/card/settings.js create mode 100644 js/component/input/checkbox.js create mode 100644 js/layer/settings.js diff --git a/api/cora/api_cache.php b/api/cora/api_cache.php index 634cc76..cacdcdb 100644 --- a/api/cora/api_cache.php +++ b/api/cora/api_cache.php @@ -57,6 +57,28 @@ class api_cache extends crud { } } + /** + * Clear the cache for a specific API call. + * + * @param $api_call The API call to clear the cache for. + * + * @return mixed The updated cache row or null if it wasn't cached. + */ + public function clear_cache($api_call) { + $key = $this->generate_key($api_call); + $cache_hits = $this->read(['key' => $key]); + + if(count($cache_hits) > 0) { + $cache_hit = $cache_hits[0]; + $attributes = []; + $attributes['expires_at'] = date('Y-m-d H:i:s', strtotime('1970-01-01 00:00:01')); + $attributes['api_cache_id'] = $cache_hit['api_cache_id']; + return $this->update($attributes); + } + + return null; + } + /** * Retrieve a cache entry with a matching key that is not expired. * diff --git a/api/cora/api_call.php b/api/cora/api_call.php index ece9ab0..9549970 100644 --- a/api/cora/api_call.php +++ b/api/cora/api_call.php @@ -37,6 +37,27 @@ final class api_call { */ private $alias; + /** + * Bypass the cache read. + * + * @var boolean + */ + private $bypass_cache_read; + + /** + * Bypass the cache write. + * + * @var boolean + */ + private $bypass_cache_write; + + /** + * Clear the cache for this API call, don't actually run the call. + * + * @var boolean + */ + private $clear_cache; + /** * The current auto-alias. If an alias is not provided, an auto-alias is * assigned. @@ -87,6 +108,29 @@ final class api_call { } else { $this->alias = $this->get_auto_alias(); } + + /** + * Note the following three parameters will come in as strings when not in + * a batch and boolean values when in a batch because of the JSON. Cast to + * boolean to support various representations. + */ + if(isset($api_call['bypass_cache_read']) === true) { + $this->bypass_cache_read = ((bool) $api_call['bypass_cache_read'] === true); + } else { + $this->bypass_cache_read = false; + } + + if(isset($api_call['bypass_cache_write']) === true) { + $this->bypass_cache_write = ((bool) $api_call['bypass_cache_write'] === true); + } else { + $this->bypass_cache_write = false; + } + + if(isset($api_call['clear_cache']) === true) { + $this->clear_cache = ((bool) $api_call['clear_cache'] === true); + } else { + $this->clear_cache = false; + } } /** @@ -103,31 +147,37 @@ final class api_call { } // Caching! If this API call is configured for caching, - // $cache_config = $this->setting->get('cache'); if( // Is cacheable isset($this->resource::$cache) === true && isset($this->resource::$cache[$this->method]) === true ) { $api_cache_instance = new api_cache(); - $api_cache = $api_cache_instance->retrieve($this); - - if($api_cache !== null) { - // If there was a cache entry available, use that. - $this->response = $api_cache['response_data']; - $this->cached_until = date('Y-m-d H:i:s', strtotime($api_cache['expires_at'])); + if($this->clear_cache === true) { + $this->response = $api_cache_instance->clear_cache($this); + $this->cached_until = date('Y-m-d H:i:s', strtotime('1970-01-01 00:00:01')); } else { - // Else just run the API call, then cache it. - $this->response = call_user_func_array( - [$resource_instance, $this->method], - $this->arguments - ); + $api_cache = $api_cache_instance->retrieve($this); - $api_cache = $api_cache_instance->cache( - $this, - $this->response, - $this->resource::$cache[$this->method] - ); - $this->cached_until = date('Y-m-d H:i:s', strtotime($api_cache['expires_at'])); + if($api_cache !== null && $this->bypass_cache_read === false) { + // If there was a cache entry available, use that. + $this->response = $api_cache['response_data']; + $this->cached_until = date('Y-m-d H:i:s', strtotime($api_cache['expires_at'])); + } else { + // Else just run the API call, then cache it. + $this->response = call_user_func_array( + [$resource_instance, $this->method], + $this->arguments + ); + + if($this->bypass_cache_write === false) { + $api_cache = $api_cache_instance->cache( + $this, + $this->response, + $this->resource::$cache[$this->method] + ); + $this->cached_until = date('Y-m-d H:i:s', strtotime($api_cache['expires_at'])); + } + } } } else { // Not cacheable diff --git a/api/profile.php b/api/profile.php index a8dae15..1bcaab5 100644 --- a/api/profile.php +++ b/api/profile.php @@ -114,14 +114,18 @@ class profile extends cora\api { */ $max_lookahead = 1800; // 30 min + // Get some stuff + $thermostat = $this->api('thermostat', 'get', $thermostat_id); + /** * Attempt to ignore the effects of solar heating by only looking at * samples when the sun is down. */ - $ignore_solar_heating = false; - - // Get some stuff - $thermostat = $this->api('thermostat', 'get', $thermostat_id); + $ignore_solar_gain = $this->api( + 'user', + 'get_setting', + 'thermostat.' . $thermostat_id . '.profile.ignore_solar_gain' + ); if($thermostat['system_type']['reported']['heat']['equipment'] !== null) { $system_type_heat = $thermostat['system_type']['reported']['heat']['equipment']; @@ -165,6 +169,26 @@ class profile extends cora\api { ] ); + // Get latitude/longitude. If that's not possible, disable solar gain + // check. + if($ignore_solar_gain === true) { + if($thermostat['address_id'] === null) { + $ignore_solar_gain = false; + } else { + $address = $this->api('address', 'get', $thermostat['address_id']); + if( + isset($address['normalized']['metadata']) === false || + isset($address['normalized']['metadata']['latitude']) === false || + isset($address['normalized']['metadata']['longitude']) === false + ) { + $ignore_solar_gain = false; + } else { + $latitude = $address['normalized']['metadata']['latitude']; + $longitude = $address['normalized']['metadata']['longitude']; + } + } + } + // Get all of the relevant data $thermostat_ids = []; foreach($group_thermostats as $group_thermostat) { @@ -254,7 +278,6 @@ class profile extends cora\api { $degree_days_temperatures = []; while($row = $result->fetch_assoc()) { $timestamp = strtotime($row['timestamp']); - $hour = date('G', $timestamp); $date = date('Y-m-d', $timestamp); // Degree days @@ -308,12 +331,15 @@ class profile extends cora\api { $runtime_seconds['cool_1'] += $row['cool_1']; $runtime_seconds['cool_2'] += $row['cool_2']; - if ( - $ignore_solar_heating === true && - $hour > 6 && - $hour < 22 - ) { - continue; + // Ignore data between sunrise and sunset. + if($ignore_solar_gain === true) { + $sun_info = date_sun_info($timestamp, $latitude, $longitude); + if( + $timestamp > $sun_info['sunrise'] && + $timestamp < $sun_info['sunset'] + ) { + continue; + } } if (isset($runtime[$timestamp]) === false) { diff --git a/api/thermostat.php b/api/thermostat.php index f67366e..5b269a5 100644 --- a/api/thermostat.php +++ b/api/thermostat.php @@ -16,7 +16,8 @@ class thermostat extends cora\crud { 'set_reported_system_types', 'generate_profile', 'generate_profiles', - 'get_metrics' + 'get_metrics', + 'update' ], 'public' => [] ]; @@ -240,7 +241,8 @@ class thermostat extends cora\crud { } /** - * Generate a new profile for this thermostat. + * Generate a new profile for this thermostat. This is called from the GUI + * often but is cached. * * @param int $thermostat_id */ diff --git a/api/user.php b/api/user.php index e8d9a3e..66e5d29 100644 --- a/api/user.php +++ b/api/user.php @@ -166,8 +166,9 @@ class user extends cora\crud { $settings = $user['settings']; } - $settings[$key] = $value; + $settings = $this->update_setting_($settings, $key, $value); + // Disallow setting changes in the demo. if($this->setting->is_demo() === false) { $this->update( [ @@ -180,6 +181,72 @@ class user extends cora\crud { return $settings; } + /** + * Recursively update the setting array. + * + * @param array $settings Settings array + * @param string $key Key to update. Dots indicate a path. + * @param mixed $value Value to set. + * + * @return array Updated settings array. + */ + private function update_setting_($settings, $key, $value) { + $path = explode('.', $key); + if(count($path) > 1) { + $this_key = array_shift($path); + if(isset($settings[$this_key]) === false) { + $settings[$this_key] = []; + } + $settings[$this_key] = $this->update_setting_( + $settings[$this_key], + implode('.', $path), + $value + ); + } else { + $settings[$key] = $value; + } + + return $settings; + } + + /** + * Get a specific setting. + * + * @param string $key The setting to get. Supports dotted paths. + * + * @return mixed The setting. Null if not set. + */ + public function get_setting($key) { + $user = $this->get($this->session->get_user_id()); + return $this->get_setting_($user['settings'], $key); + } + + /** + * Recursive helper function for getting a setting. + * + * @param array $settings Settings array + * @param string $key The key of the setting to get. + * + * @return mixed The setting. Null if not set. + */ + private function get_setting_($settings, $key) { + $path = explode('.', $key); + if(count($path) > 1) { + $this_key = array_shift($path); + if(isset($settings[$this_key]) === true) { + return $this->get_setting_($settings[$this_key], implode('.', $path)); + } else { + return null; + } + } else { + if(isset($settings[$key]) === true) { + return $settings[$key]; + } else { + return null; + } + } + } + /** * Set a sync_status on a user to the current datetime. * diff --git a/css/dashboard.css b/css/dashboard.css index 099d401..918294b 100644 --- a/css/dashboard.css +++ b/css/dashboard.css @@ -218,6 +218,56 @@ a.inverted:active { } +/** + * Inputs + */ +input[type=checkbox] { + visibility: hidden; +} + +.checkbox { + position: relative; + user-select: none; + margin-bottom: 4px; +} + +.checkbox label { + cursor: pointer; + position: absolute; + width: 20px; + height: 20px; + top: 0; + left: 0; + border-radius: 4px; + box-shadow: inset 0px 1px 1px rgba(0,0,0,0.5), 0px 1px 0px rgba(255, 255, 255, 0.4); + background: #37474f; /* bluegray light */ +} + +.checkbox label::after { + opacity: 0; + content: ''; + position: absolute; + width: 12px; + height: 8px; + background: transparent; + top: 5px; + left: 4px; + border: 3px solid #20bf6b; + border-top: none; + border-right: none; + transform: rotate(-45deg); + transition: opacity 200ms ease; + +} + +.checkbox input[type=checkbox]:checked + label:after { + opacity: 1; +} + +.checkbox input[type=checkbox]:disabled + label:after { + opacity: 0.25; +} + /** * This is a stripped down version of https://flexgridlite.elliotdahl.com/ @@ -344,6 +394,7 @@ a.inverted:active { .icon.close_network:before { content: "\F015B"; } .icon.cloud_question:before { content: "\F0A39"; } .icon.code_tags:before { content: "\F0174"; } +.icon.cog:before { content: "\F0493"; } .icon.dots_vertical:before { content: "\F01D9"; } .icon.download:before { content: "\F01DA"; } .icon.earth:before { content: "\F01E7"; } diff --git a/js/beestat/api.js b/js/beestat/api.js index 9cb7625..45388e6 100644 --- a/js/beestat/api.js +++ b/js/beestat/api.js @@ -114,18 +114,30 @@ beestat.api.prototype.send = function(opt_api_call) { * @param {string} method The method. * @param {Object=} opt_args Optional arguments. * @param {string=} opt_alias Optional alias (required for batch API calls). + * @param {boolean=} opt_bypass_cache_read Optional bypass of cache read. + * @param {boolean=} opt_bypass_cache_write Optional bypass of cache write. * * @return {beestat.api} This. */ -beestat.api.prototype.add_call = function(resource, method, opt_args, opt_alias) { +beestat.api.prototype.add_call = function(resource, method, opt_args, opt_alias, opt_bypass_cache_read, opt_bypass_cache_write, opt_clear_cache) { var api_call = { 'resource': resource, 'method': method, 'arguments': JSON.stringify(beestat.default_value(opt_args, {})) }; + if (opt_alias !== undefined) { api_call.alias = opt_alias; } + if (opt_bypass_cache_read !== undefined) { + api_call.bypass_cache_read = opt_bypass_cache_read; + } + if (opt_bypass_cache_write !== undefined) { + api_call.bypass_cache_write = opt_bypass_cache_write; + } + if (opt_clear_cache !== undefined) { + api_call.clear_cache = opt_clear_cache; + } this.api_calls_.push(api_call); @@ -296,7 +308,9 @@ beestat.api.prototype.get_cached_ = function(api_call) { var cached = beestat.api.cache[this.get_key_(api_call)]; if ( cached !== undefined && - moment().isAfter(cached.until) === false + moment().isAfter(cached.until) === false && + api_call.bypass_cache_read !== true && + api_call.clear_cache !== true ) { return cached; } diff --git a/js/beestat/setting.js b/js/beestat/setting.js index b458699..6fa8235 100644 --- a/js/beestat/setting.js +++ b/js/beestat/setting.js @@ -1,17 +1,30 @@ /** - * Get or set a setting. + * Get or set a setting. ESLint Forgive my variable naming sins for the sake + * of no-shadow. * - * @param {mixed} key If a string, get/set that specific key. If an object, set all the specified keys in the object. + * @param {mixed} argument_1 If a string, get/set that specific key. If an + * object, set all the specified keys in the object. * @param {mixed} opt_value If a string, set the specified key to this value. * @param {mixed} opt_callback Optional callback. * * @return {mixed} The setting if requesting (undefined if not set), undefined * otherwise. */ -beestat.setting = function(key, opt_value, opt_callback) { - var user = beestat.user.get(); +beestat.setting = function(argument_1, opt_value, opt_callback) { + const user = beestat.user.get(); + if (user.settings === null) { + user.settings = {}; + } - var defaults = { + // TODO Some of these are still strings instead of ints in the database. + if (user.settings.thermostat_id !== undefined) { + user.settings.thermostat_id = parseInt( + user.settings.thermostat_id, + 10 + ); + } + + const defaults = { 'runtime_thermostat_detail_range_type': 'dynamic', 'runtime_thermostat_detail_range_static_begin': moment() .subtract(3, 'day') @@ -37,49 +50,113 @@ beestat.setting = function(key, opt_value, opt_callback) { 'temperature_unit': '°F', - 'first_run': true + 'first_run': true, + + 'thermostat.#.profile.ignore_solar_gain': false }; - if (user.settings === null) { - user.settings = {}; - } - - /* - * TODO This is temporary until I get all the setting data types under - * control. Just doing this so other parts of the application can be built out - * properly. - */ - if (user.settings.thermostat_id !== undefined) { - user.settings.thermostat_id = parseInt( - user.settings.thermostat_id, - 10 - ); - } - - if (opt_value === undefined && typeof key !== 'object') { - if (user.settings[key] !== undefined) { - return user.settings[key]; - } else if (defaults[key] !== undefined) { - return defaults[key]; - } - return undefined; - } - var settings; - if (typeof key === 'object') { - settings = key; + // Figure out what we're trying to do. + let settings; + let key; + let mode; + if (typeof argument_1 === 'object') { + settings = argument_1; } else { - settings = {}; - settings[key] = opt_value; + key = argument_1; + + if (opt_value !== undefined) { + settings = {}; + settings[key] = opt_value; + } + } + mode = (settings !== undefined || opt_value !== undefined) ? 'set' : 'get'; + + // Get the requested value. + if (mode === 'get') { + /** + * Get a value nested in an object from a string path. + * + * @param {object} o The object to search in. + * @param {string} p) The path (ex: thermostat.1.profile.ignore_solar_gain) + * + * @throws {exception} If the path is invalid. + * @return {mixed} The value, or undefined if it doesn't exist. + */ + const get_value_from_path = (o, p) => p.split('.').reduce((a, v) => a[v], o); + + /** + * Get the default value of a setting. + * + * @param {string} k The setting to get. + * + * @return {mixed} The default value, or undefined if there is none. + */ + const get_default_value = function(k) { + // Replace any numeric key parts with a # as a placeholder. + let old_parts = k.split('.'); + let new_parts = []; + old_parts.forEach(function(part) { + if (isNaN(part) === false) { + new_parts.push('#'); + } else { + new_parts.push(part); + } + }); + + return defaults[new_parts.join('.')]; + }; + + let value; + try { + value = get_value_from_path(user.settings, key); + } catch (error) { + value = undefined; + } + + return (value === undefined ? get_default_value(key) : value); } - var api = new beestat.api(); + // Set the requested value. + + /** + * Recursively update the setting object. + * + * @param {object} user_settings Settings object + * @param {string} k Key to update. Dots indicate a path. + * @param {mixed} v Value to set + * + * @return {object} Updated settings object. + */ + const update_setting = function(user_settings, k, v) { + let path = k.split('.'); + if (path.length > 1) { + const this_key = path.shift(); + if (user_settings[this_key] === undefined) { + user_settings[this_key] = {}; + } + if (typeof user_settings[this_key] !== 'object') { + throw new Error('Tried to set sub-key of non-object setting.'); + } + user_settings[this_key] = update_setting( + user_settings[this_key], + path.join('.'), + v + ); + } else { + user_settings[k] = v; + } + + return user_settings; + }; + + const api = new beestat.api(); api.set_callback(opt_callback); - var has_calls = false; + let has_calls = false; - for (var k in settings) { - if (user.settings[k] !== settings[k]) { - user.settings[k] = settings[k]; + for (let k in settings) { + if (beestat.setting(k) !== settings[k]) { + user.settings = update_setting(user.settings, k, settings[k]); beestat.dispatcher.dispatchEvent('setting.' + k); @@ -107,4 +184,6 @@ beestat.setting = function(key, opt_value, opt_callback) { opt_callback(); } } + + return undefined; }; diff --git a/js/component/card/runtime_thermostat_summary.js b/js/component/card/runtime_thermostat_summary.js index 7e8389f..8f301ad 100755 --- a/js/component/card/runtime_thermostat_summary.js +++ b/js/component/card/runtime_thermostat_summary.js @@ -580,38 +580,6 @@ beestat.component.card.runtime_thermostat_summary.prototype.decorate_top_right_ .set_callback(function() { self.chart_.reset_zoom(); })); - - if (beestat.setting('runtime_thermostat_summary_gap_fill') === true) { - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Disable Gap Fill') - .set_icon('basket_unfill') - .set_callback(function() { - beestat.setting('runtime_thermostat_summary_gap_fill', false); - })); - } else { - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Enable Gap Fill') - .set_icon('basket_fill') - .set_callback(function() { - beestat.setting('runtime_thermostat_summary_gap_fill', true); - })); - } - - if (beestat.setting('runtime_thermostat_summary_smart_scale') === true) { - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Disable Smart Scale') - .set_icon('network_strength_off') - .set_callback(function() { - beestat.setting('runtime_thermostat_summary_smart_scale', false); - })); - } else { - menu.add_menu_item(new beestat.component.menu_item() - .set_text('Enable Smart Scale') - .set_icon('network_strength_4') - .set_callback(function() { - beestat.setting('runtime_thermostat_summary_smart_scale', true); - })); - } } menu.add_menu_item(new beestat.component.menu_item() diff --git a/js/component/card/settings.js b/js/component/card/settings.js new file mode 100644 index 0000000..3369fdf --- /dev/null +++ b/js/component/card/settings.js @@ -0,0 +1,145 @@ +/** + * Setting + */ +beestat.component.card.settings = function() { + beestat.component.card.apply(this, arguments); +}; +beestat.extend(beestat.component.card.settings, beestat.component.card); + +/** + * Decorate contents. + * + * @param {rocket.Elements} parent Parent + */ +beestat.component.card.settings.prototype.decorate_contents_ = function(parent) { + const thermostat = beestat.cache.thermostat[ + beestat.setting('thermostat_id') + ]; + + parent.appendChild( + $.createElement('p') + .style('font-weight', '400') + .innerText('Thermostat Summary') + ); + + // Gap Fill + const enable_gap_fill = new beestat.component.input.checkbox(); + enable_gap_fill + .set_label('Enable Gap Fill') + .set_value(beestat.setting('runtime_thermostat_summary_gap_fill')) + .render(parent); + + enable_gap_fill.addEventListener('change', function() { + enable_gap_fill.disable(); + beestat.setting( + 'runtime_thermostat_summary_gap_fill', + enable_gap_fill.get_value(), + function() { + enable_gap_fill.enable(); + } + ); + }); + + // Smart Scale + const enable_smart_scale = new beestat.component.input.checkbox(); + enable_smart_scale + .set_label('Enable Smart Scale') + .set_value(beestat.setting('runtime_thermostat_summary_smart_scale')) + .render(parent); + + enable_smart_scale.addEventListener('change', function() { + enable_smart_scale.disable(); + beestat.setting( + 'runtime_thermostat_summary_smart_scale', + enable_smart_scale.get_value(), + function() { + enable_smart_scale.enable(); + } + ); + }); + + parent.appendChild( + $.createElement('p') + .style('font-weight', '400') + .innerText('Temperature Profiles') + ); + const ignore_solar_gain = new beestat.component.input.checkbox(); + const ignore_solar_gain_key = 'thermostat.' + thermostat.thermostat_id + '.profile.ignore_solar_gain'; + ignore_solar_gain + .set_label('Ignore Solar Gain') + .set_value(beestat.setting(ignore_solar_gain_key)) + .render(parent); + + ignore_solar_gain.addEventListener('change', function() { + ignore_solar_gain.disable(); + beestat.setting( + ignore_solar_gain_key, + ignore_solar_gain.get_value(), + function() { + new beestat.api() + .add_call( + 'thermostat', + 'generate_profile', + { + 'thermostat_id': thermostat.thermostat_id + }, + undefined, + undefined, + undefined, + // Clear cache + true + ) + .add_call( + 'thermostat', + 'update', + { + 'attributes': { + 'thermostat_id': thermostat.thermostat_id, + 'profile': null + } + } + ) + .add_call( + 'thermostat', + 'read_id', + { + 'attributes': { + 'inactive': 0 + } + }, + 'thermostat' + ) + .set_callback(function(response) { + ignore_solar_gain.enable(); + beestat.cache.set('thermostat', response.thermostat); + }) + .send(); + } + ); + }); +}; + +/** + * Get the title of the card. + * + * @return {string} The title. + */ +beestat.component.card.settings.prototype.get_title_ = function() { + return 'Settings'; +}; + +/** + * Decorate the menu + * + * @param {rocket.Elements} parent + */ +beestat.component.card.settings.prototype.decorate_top_right_ = function(parent) { + var menu = (new beestat.component.menu()).render(parent); + + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Help') + .set_icon('help_circle') + .set_callback(function() { + window.open('https://doc.beestat.io/9d01e7256390473ca8121d4098d91c9d'); + })); +}; diff --git a/js/component/card/temperature_profiles.js b/js/component/card/temperature_profiles.js index 3d7590b..8ffbb76 100644 --- a/js/component/card/temperature_profiles.js +++ b/js/component/card/temperature_profiles.js @@ -165,6 +165,7 @@ beestat.component.card.temperature_profiles.prototype.get_data_ = function() { parseFloat(x_fixed), profile.deltas[x_fixed] ]); + y_min = Math.min(y_min, profile.deltas[x_fixed]); y_max = Math.max(y_max, profile.deltas[x_fixed]); } diff --git a/js/component/header.js b/js/component/header.js index 4f25eb6..d91833f 100644 --- a/js/component/header.js +++ b/js/component/header.js @@ -184,6 +184,13 @@ beestat.component.header.prototype.decorate_ = function(parent) { } } + menu.add_menu_item(new beestat.component.menu_item() + .set_text('Settings') + .set_icon('cog') + .set_callback(function() { + (new beestat.layer.settings()).render(); + })); + menu.add_menu_item(new beestat.component.menu_item() .set_text('Log Out') .set_icon('exit_to_app') diff --git a/js/component/input.js b/js/component/input.js index 184c26f..38657a6 100644 --- a/js/component/input.js +++ b/js/component/input.js @@ -2,6 +2,7 @@ * Input parent class. */ beestat.component.input = function() { + this.uuid_ = this.generate_uuid_(); beestat.component.apply(this, arguments); }; beestat.extend(beestat.component.input, beestat.component); @@ -48,3 +49,15 @@ beestat.component.input.prototype.set_ = function(key, value) { return this; }; + +/** + * Generate a UUID to uniquely identify an input. + * + * @link https://stackoverflow.com/a/2117523 + * @return {string} The UUID; + */ +beestat.component.input.prototype.generate_uuid_ = function() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +}; diff --git a/js/component/input/checkbox.js b/js/component/input/checkbox.js new file mode 100644 index 0000000..0fa5c85 --- /dev/null +++ b/js/component/input/checkbox.js @@ -0,0 +1,85 @@ +/** + * Checkbox parent class. + */ +beestat.component.input.checkbox = function() { + this.input_ = $.createElement('input'); + + beestat.component.input.apply(this, arguments); +}; +beestat.extend(beestat.component.input.checkbox, beestat.component.input); + +/** + * Decorate + * + * @param {rocket.Elements} parent + */ +beestat.component.input.checkbox.prototype.decorate_ = function(parent) { + var self = this; + + const div = $.createElement('div').addClass('checkbox'); + this.input_ + .setAttribute('id', this.uuid_) + .setAttribute('type', 'checkbox'); + div.appendChild(this.input_); + + const label = $.createElement('label'); + label.setAttribute('for', this.uuid_); + div.appendChild(label); + + const text_label = $.createElement('span') + .style({ + 'cursor': 'pointer', + 'margin-left': (beestat.style.size.gutter / 2) + }) + .innerText(this.label_) + .addEventListener('click', function() { + self.input_[0].click(); + // self.input_.checked(!self.input_.checked()); + }); + div.appendChild(text_label); + + this.input_.addEventListener('change', function() { + // console.log('input changed'); + self.dispatchEvent('change'); + }); + + parent.appendChild(div); +}; + +/** + * Set the value in the input field. This bypasses the set_ function to avoid + * rerendering when the input value is set. It's unnecessary and can also + * cause minor issues if you try to set the value, then do something else with + * the input immediately after. + * + * This will not fire off the change event listener. + * + * @param {string} value + * + * @return {beestat.component.input.checkbox} This. + */ +beestat.component.input.checkbox.prototype.set_value = function(value) { + this.input_.checked(value); + return this; +}; + +/** + * Get the value in the input field. + * + * @return {string} The value in the input field. + */ +beestat.component.input.checkbox.prototype.get_value = function() { + return this.input_.checked(); +}; + +/** + * Set the checkbox label. + * + * @param {string} label + * + * @return {beestat.component.input.checkbox} This. + */ +beestat.component.input.checkbox.prototype.set_label = function(label) { + this.label_ = label; + return this; +}; diff --git a/js/component/input/text.js b/js/component/input/text.js index 7e407d7..b79f0b8 100644 --- a/js/component/input/text.js +++ b/js/component/input/text.js @@ -20,7 +20,7 @@ beestat.component.input.text = function() { }); }); - beestat.component.apply(this, arguments); + beestat.component.input.apply(this, arguments); }; beestat.extend(beestat.component.input.text, beestat.component.input); diff --git a/js/js.php b/js/js.php index aca7a5a..cd66ed0 100755 --- a/js/js.php +++ b/js/js.php @@ -44,6 +44,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; // Component echo '' . PHP_EOL; @@ -64,6 +65,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; @@ -95,6 +97,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; diff --git a/js/layer/settings.js b/js/layer/settings.js new file mode 100644 index 0000000..37d5ade --- /dev/null +++ b/js/layer/settings.js @@ -0,0 +1,51 @@ +/** + * Setting layer. + */ +beestat.layer.settings = function() { + beestat.layer.apply(this, arguments); +}; +beestat.extend(beestat.layer.settings, beestat.layer); + +beestat.layer.settings.prototype.decorate_ = function(parent) { + /* + * Set the overflow on the body so the scrollbar is always present so + * highcharts graphs render properly. + */ + $('body').style({ + 'overflow-y': 'scroll', + 'background': beestat.style.color.bluegray.light, + 'padding': '0 ' + beestat.style.size.gutter + 'px' + }); + + (new beestat.component.header('setting')).render(parent); + + // All the cards + const cards = []; + + if (window.is_demo === true) { + cards.push([ + { + 'card': new beestat.component.card.demo(), + 'size': 12 + } + ]); + } + + // Settings + cards.push([ + { + 'card': new beestat.component.card.settings(), + 'size': 12 + } + ]); + + // Footer + cards.push([ + { + 'card': new beestat.component.card.footer(), + 'size': 12 + } + ]); + + (new beestat.component.layout(cards)).render(parent); +};