Sisense Community logo
    • Community Feedback
    • Chapters
    • Events
    • Forums
      • Help and How To
      • Product Feedback Forum
      • Strategy & Use Cases
    • Blogs
    • KB Docs
      • KB Docs
      • Add-Ons & Plug-Ins
      • APIs
      • Best Practices
      • Blox
      • CDT
      • Cloud Managed Service
      • Data Models
      • Data Sources
      • Embedding Analytics
      • How-Tos & FAQs
      • Onboarding
      • PySisense
      • Security
      • Sisense Administration
      • Sisense Intelligence & AI
      • Troubleshooting
      • Widget & Dashboard Scripts
    • Support
    • Learning
      • Sisense Academy: Free Courses and Certifications
      • Official Developer Documentation
      • Official Product Documentation
      • Official Sisense Youtube Channel
      • Sisense Compose SDK Playground
      • Official Sisense Discord
    • Use Case Gallery
    Discussions
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
    •                    
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   
    Discussions
    • TagsChevronRightIcon
    Analytics: Blox
    • Blog banner
      • PySisenseChevronRightIcon

      Bulk Updating BloX Widget Fonts with PySisense

                                                                                                       

      Bulk Updating BloX Widget Fonts with PySisense Overview BloX widgets do not inherit the font configured in a Sisense instance's Look and Feel settings. Changing the tenant level font has no effect on existing BloX widgets, and updating each one by hand through the BloX template editor becomes impractical once a dashboard has more than a few of these widgets, or the same change needs to be applied across many dashboards. This article describes blox_font_updater.py , a Python command-line tool that finds BloX widgets across a Sisense tenant and updates their font using the pySisense library. The tool changes only the font of BloX widgets. It does not modify any other widget type, and it does not change the tenant level Look and Feel font; it edits each BloX widget's own style or font configuration directly. Full coverage is not guaranteed on every widget: BloX widgets are built from open ended custom HTML and JSON, so a small number may still need a manual adjustment after a run. The full source is available on GitHub in this repository . Two ways to change a BloX widget's font A BloX widget's font can be set in two different places, and the tool can use either one, or both: CSS rule : adds a CSS rule to the front of the widget's existing custom style. A wildcard selector targets nearly every element in the widget, including elements such as input fields that do not otherwise inherit a font from their surroundings. Font configuration : sets Sisense's own font setting for the widget directly. This is closer to how a BloX widget's font is normally configured, but depends on the widget's own template reading that setting, so it can miss some elements. Both mechanisms are applied by default. A widget that is already correct for one mechanism is left unchanged for that mechanism even when both are in use, so a run only touches whatever is actually out of date. About PySisense PySisense is a Python SDK for the Sisense REST API, installed via pip. It provides wrappers around common Sisense operations, including dashboard and widget access, and handles authentication, session management, and request errors internally. This tool uses pySisense's SisenseClient and Dashboard classes to discover dashboards and widgets, and to read and write BloX widget definitions. Prerequisites Python 3.13 or later. pySisense and PyYAML, installed with pip install -r requirements.txt . A Sisense API token, ideally belonging to a Sisense administrator. An administrator token lets the tool discover and update BloX widgets across the whole tenant, rather than only dashboards owned by or shared with one user. Setup Copy the example configuration and fill in the Sisense domain and API token: cp config.example.yaml config.yaml The tool is ready to run once domain and token are filled in. The remaining settings control the font change itself and are covered in the next section. Configuration reference # Copy this file to config.yaml and fill in real values: # # cp config.example.yaml config.yaml # # config.yaml contains a real credential once filled in - never send it to # anyone else or commit it to source control. When sharing this tool with # someone else, share this file (config.example.yaml), not a filled-in # config.yaml. # The Sisense server's address. A "http://" or "https://" prefix is fine # here if included - it's ignored either way, since is_ssl below is what # actually decides whether HTTPS or HTTP is used. domain: "your-tenant.sisense.com" # Leave this as true unless the Sisense server uses plain HTTP. is_ssl: true # A Sisense API token. Recommended: a Sisense administrator's token - see # is_user_admin below, and the README's "Getting a Sisense API token" and # "Permissions" sections. token: "YOUR_SISENSE_API_TOKEN" # The kind of widget this tool looks for. Leave this as BloX. widget_type: BloX # Recommended: true, with an administrator token (see token above) - finds # widgets across every dashboard in the tenant, not just ones owned by or # shared with one particular user, and an administrator is also the most # likely to already have the access needed to update most of them. # Set to false to only look at dashboards the API user can already see. is_user_admin: true # Even with is_user_admin above, Sisense can still deny a widget update with # "access denied" if the API user has no owner or edit share on that specific # dashboard - admin access relaxes what can be read, not what can be written. # Set this to true to have the tool work around that automatically: for a # widget that fails this way, it temporarily makes the API user the # dashboard's owner, applies the change, then restores the original owner and # shares. Leave this as false unless that "access denied" failure is actually # being seen - see the README's "Permissions" section for the tradeoffs. use_temporary_ownership: false # The settings below control what the tool does by default when run # without the matching command-line option. Anything here can still be # overridden per run - for example, --font "Georgia, serif" always wins over # default_font_family below, no matter what it's set to. # The CSS selector used when default_font_mode is css or both (see README.md). default_selector: ".blox-slides *" # The font used for the change - a full CSS font stack (e.g. with fallback # fonts). Used as-is for default_font_mode: css. For default_font_mode: # config, only the first name is used (e.g. "Arial" here) - that's the # typical, well-supported value for Sisense's font config field, even though # it has also been observed to accept a full stack like this one directly. default_font_family: "Arial, Helvetica, sans-serif" # One of: css, config, both - see "Two ways to change the font" in # blox_font_updater.py, or the README, for what each one does. "both" is # the default here for the widest out-of-the-box coverage. default_font_mode: both # Maximum number of widgets a single run will touch. 0 (or a negative # number) means no limit. default_max_widgets: 10 domain , is_ssl , token : connection details for the Sisense instance. A http:// or https:// prefix on domain is ignored either way; is_ssl decides the scheme. widget_type : left as BloX . is_user_admin : true to discover widgets across the whole tenant with an administrator token, false to only look at dashboards the token's own user can already see. default_selector : the CSS selector used when default_font_mode is css or both . default_font_family : the font applied. A full CSS font stack is used as is for the CSS rule; only the first name is used for the font configuration field. default_font_mode : css , config , or both , as described above. default_max_widgets : how many widgets a single run touches. 0 or a negative number removes the limit. use_temporary_ownership : false by default. Set to true to have the tool temporarily take ownership of a dashboard when a widget update is denied with "access denied," even with is_user_admin set. Each of these can be overridden for a single run with a matching command-line option ( --selector , --font , --font-mode , --max-widgets ) without changing config.yaml . Usage Run with no options to preview which widgets would change, without writing anything: python blox_font_updater.py Apply the change to a single widget first, then confirm it in Sisense: python blox_font_updater.py --apply --max-widgets 1 If the result is not correct, every change made by the tool can be reverted: python blox_font_updater.py --undo Once confirmed, apply the change across the tenant: python blox_font_updater.py --apply --all Common use cases Rolling out a font change across a tenant Set default_selector , default_font_family , and default_font_mode in config.yaml to the desired values, then run the sequence in Usage: preview, apply to one widget, confirm, apply to all. Using a different font for a single run, without changing config.yaml python blox_font_updater.py --apply --font "Georgia, serif" --font-mode config Reducing log output on a large run python blox_font_updater.py --apply --all --quiet Apply Change to One Specific Widget ID Only python blox_font_updater.py --apply --dashboard-id <dashboardId> --widget-id <widgetId> Logs and backups Every run writes to logs/blox_font_update.log . Before any widget is changed, its complete prior definition is recorded in logs/blox_font_backup.json . --undo reads that file and restores every change that has not already been undone, most recent first. Both paths can be changed with --log-file and --backup-file . Full option reference usage: blox_font_updater.py [-h] [--config PATH] [--selector CSS_SELECTOR] [--font FONT_FAMILY] [--font-mode {css,config,both}] [--max-widgets N] [--all] [--dashboard-id ID] [--widget-id ID] [--apply] [--force] [--undo] [--backup-file PATH] [--log-file PATH] [--quiet] [--verbose] Change the font used by BloX widgets on a Sisense instance. options: -h, --help show this help message and exit --config PATH Path to config.yaml. --selector CSS_SELECTOR Override default_selector from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: '.blox-slides *'. --font FONT_FAMILY Override default_font_family from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: 'Arial, Helvetica, sans- serif'. --font-mode {css,config,both} Override default_font_mode from config.yaml, for this run only - normally set in config.yaml instead. Used if neither is set: 'both'. 'css' adds a CSS rule that directly targets nearly every element, including custom HTML and input fields. 'config' sets Sisense's simpler built-in font setting instead, which some widgets' own elements (including input fields) may not pick up. Neither is guaranteed to cover everything - BloX templates vary - so 'both' applies both changes for the best chance of full coverage. --max-widgets N Override default_max_widgets from config.yaml, for this run only. Normally set in config.yaml instead. Used if neither is set: 10. 0 or a negative number means no limit - same as --all. --all Process every matching widget, with no limit. Overrides --max-widgets. --dashboard-id ID Target one already-known widget directly by dashboard and widget id, instead of scanning the tenant. Must be used together with --widget-id. --max-widgets/--all are ignored in this mode. --widget-id ID Used together with --dashboard-id. See --dashboard-id. --apply Actually write changes to Sisense. Without this flag, the tool only previews what it would do. --force Re-apply even to widgets that already look up to date for the selected --font-mode. --undo Revert every not-yet-undone change recorded in --backup-file, then exit. --backup-file PATH Where change backups are stored. --log-file PATH Where the run log is written. --quiet Only log the scan summary and any warnings/errors - skip the per-widget lines. Useful for large runs. --verbose Show full style strings in the log instead of a truncated preview. Use case Using blox_font_updater.py instead of manual BloX template edits offers several advantages: Applies a font change consistently across every BloX widget in a tenant, without opening each widget individually. Provides automatic rollback through the backup file, without a separate export or snapshot step. Supports two independent font change mechanisms, so a widget that does not respond to one still receives the font through the other. Reduces the time needed to test a change safely, through a preview mode and a single widget run before a full rollout. Summary blox_font_updater.py gives Sisense administrators a repeatable way to change the font of BloX widgets across a tenant from the command line, with a preview step, a backup of every change, and full reversal through --undo . config.yaml serves as the source of truth for the selector, font, and mode used, and can be adjusted per environment without changing the script itself. The full source is available on GitHub and can be modified as needed. """ bloxFontUpdater - bulk-update the font used by Sisense BloX widgets. BloX widgets don't inherit Sisense's tenant-level "Look and Feel" font, so this tool sets one directly, across every BloX widget it finds, by adding a CSS rule, by setting Sisense's own font config field, or both (see resolveSetting/applyChanges below and "Two ways to change the font" in README.md). Preview-only by default; every change is backed up and reversible with --undo. Full documentation, including configuration and the two font-change mechanisms: README.md Command-line options: --help python blox_font_updater.py # preview only, changes nothing python blox_font_updater.py --apply --max-widgets 1 python blox_font_updater.py --undo """ import argparse import base64 import copy import json import logging import os import sys from datetime import datetime, timezone _installHint = ( "Missing dependency: {module}.\n\n" "Install the required packages first, for example:\n" " pip install -r requirements.txt\n" "or, with uv:\n" " uv sync\n" ) try: import yaml except ImportError: sys.exit(_installHint.format(module="PyYAML")) try: from pysisense import SisenseClient, Dashboard except ImportError: sys.exit(_installHint.format(module="pysisense")) # --------------------------------------------------------------------------- # Fallback values, used only for a setting that is given on neither the # command line nor in config.yaml. Most users should set default_selector, # default_font_family, default_font_mode, and default_max_widgets in # config.yaml instead of changing these - see config.example.yaml. # --------------------------------------------------------------------------- fallbackSelector = ".blox-slides *" fallbackFontFamily = "Arial, Helvetica, sans-serif" # "both" gives the widest out-of-the-box coverage, so it's the fallback - # see "Two ways to change the font" in README.md. fallbackFontMode = "both" # Safety cap on how many widgets a single run will touch. Set # default_max_widgets to 0 in config.yaml (or pass --all) to remove the cap. fallbackMaxWidgets = 10 # The only valid values for config.yaml's default_font_mode (or --font-mode). fontModeChoices = ("css", "config", "both") scriptDir = os.path.dirname(os.path.abspath(__file__)) defaultConfigPath = os.path.join(scriptDir, "config.yaml") defaultLogFile = os.path.join(scriptDir, "logs", "blox_font_update.log") defaultBackupFile = os.path.join(scriptDir, "logs", "blox_font_backup.json") # Length a style preview is truncated to in normal (non --verbose) logging, # so a widget with a very long style string doesn't flood the terminal. stylePreviewLength = 100 # Fields Sisense manages internally and does not expect to receive back when # saving a widget - present on data read from the API, but absent from a # widget definition when it is written. serverManagedWidgetFields = ( "oid", "_id", "owner", "userId", "created", "lastUpdated", "instanceType", "dashboardid", ) def setupLogger(logFile): """Log to both the console and logFile, so a run can be reviewed afterwards.""" # dirname is "" for a bare filename (e.g. --log-file run.log, meaning # "current directory") - makedirs("") raises, so only call it when # there's an actual directory to create. logDir = os.path.dirname(logFile) if logDir: os.makedirs(logDir, exist_ok=True) logger = logging.getLogger("bloxFontUpdater") logger.setLevel(logging.INFO) logger.handlers.clear() # avoid duplicate output if setupLogger is ever called twice formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") fileHandler = logging.FileHandler(logFile) fileHandler.setFormatter(formatter) logger.addHandler(fileHandler) consoleHandler = logging.StreamHandler() consoleHandler.setFormatter(formatter) logger.addHandler(consoleHandler) return logger def previewStyle(styleText, verbose): """Shorten a style string for logging, unless --verbose was requested.""" if verbose or len(styleText) <= stylePreviewLength: return styleText return styleText[:stylePreviewLength] + f"... ({len(styleText)} chars total, use --verbose to see all)" def buildCssRule(selector, fontFamily): """Build the single CSS rule that gets added to the front of a widget's style.""" return f"{selector}{{font-family: {fontFamily} !important}}" def extractPrimaryFontName(fontFamily): """ Turn a CSS font-family value into just its first font name, e.g. "Arial, Helvetica, sans-serif" -> "Arial", or '"Open Sans", sans-serif' -> "Open Sans". Used for Sisense's font config field (style.currentConfig. fontFamily), which is normally set to a single font name like this - that field has also been observed to accept a full comma-separated stack, but a single name is the more typical, well-supported value for it. """ firstFont = fontFamily.split(",")[0].strip() return firstFont.strip("'\"") def mergeStyle(existingStyle, newRule): """ Add newRule to the front of a widget's existing style, without altering the existing content in any way. A BloX widget's style string may be missing entirely, empty, a single rule, several rules concatenated together, or CSS left over from manual editing that doesn't fully make sense on its own. In every case, newRule is placed at the front and whatever was already there is kept exactly as it was - this tool never tries to interpret or "clean up" existing CSS. """ existingStyle = existingStyle or "" if not existingStyle.strip(): return newRule return f"{newRule} {existingStyle}" def ruleAlreadyApplied(existingStyle, newRule): """True if newRule is already the first thing in the style string.""" return (existingStyle or "").strip().startswith(newRule) def loadBackup(path): """Read the JSON list of previously-applied changes, or [] if none exist yet.""" if not os.path.exists(path): return [] with open(path, "r") as backupFile: try: return json.load(backupFile) except json.JSONDecodeError: return [] def saveBackup(path, records): """Persist the backup list. Called immediately after every change, so an interrupted run never loses track of widgets it already modified.""" # See setupLogger for why this only calls makedirs when there's an # actual directory component. backupDir = os.path.dirname(path) if backupDir: os.makedirs(backupDir, exist_ok=True) with open(path, "w") as backupFile: json.dump(records, backupFile, indent=2) def getWidgetEndpoint(dashboardId, widgetId, isAdmin): """Endpoint used to read a single widget's full definition.""" endpoint = f"/api/v1/dashboards/{dashboardId}/widgets/{widgetId}" if isAdmin: endpoint += "?adminAccess=true" return endpoint def putWidgetEndpoint(dashboardId, widgetId): """Endpoint used to save a widget - the same one the Sisense web UI itself uses when a widget is edited and saved.""" return f"/api/dashboards/{dashboardId}/widgets/{widgetId}" def buildPutPayload(widget): """Strip the server-managed fields Sisense does not expect back when a widget is saved (see serverManagedWidgetFields).""" return {key: value for key, value in widget.items() if key not in serverManagedWidgetFields} def describeErrorResponse(response): """ Best-effort description of a failed request, for logging. `requests.Response` objects are falsy for any non-2xx status - `if response` is True only when the request actually succeeded, not merely when a response was received. Checking `response is not None` here is what actually distinguishes "no response at all" (e.g. a network error) from "the server responded with an error", so the real error detail (e.g. "Widget not found") gets logged instead of a misleading "No response received." Also falls back gracefully if the error body isn't valid JSON. """ if response is None: return "No response received." try: return response.json() except ValueError: return response.text or f"HTTP {response.status_code}" def decodeJwtUserId(token): """ Read the "user" claim out of a Sisense API token, without verifying its signature - used only to learn the API user's own id for use_temporary_ownership, not for any security decision. Sisense API tokens are JWTs: three dot-separated, base64url-encoded segments (header, payload, signature). The payload segment decodes to a JSON object containing, among other things, "user" (the token owner's own id). Returns None if token isn't a decodable JWT of this shape. """ try: payloadSegment = token.split(".")[1] padded = payloadSegment + "=" * (-len(payloadSegment) % 4) payload = json.loads(base64.urlsafe_b64decode(padded)) except (IndexError, ValueError, TypeError): return None return payload.get("user") def getDashboardOwnerId(apiClient, dashboardId, logger): """Look up the current owner of a dashboard, for use_temporary_ownership - the same admin endpoint pysisense's own get_all_dashboards() reads from, scoped to one dashboard.""" response = apiClient.get(f"/api/v1/dashboards/admin?dashboardType=owner&id={dashboardId}&asObject=false") if not response or not response.ok: logger.error(f"Could not look up the current owner of dashboard {dashboardId}: {describeErrorResponse(response)}") return None matches = response.json() if not matches: logger.error(f"Could not look up the current owner of dashboard {dashboardId}: dashboard not found.") return None return matches[0].get("owner") def getDashboardShares(apiClient, dashboardId, logger): """Read a dashboard's current share list, so use_temporary_ownership can restore it exactly after a temporary ownership change.""" response = apiClient.get(f"/api/shares/dashboard/{dashboardId}?adminAccess=true") if not response or not response.ok: logger.error(f"Could not read the current shares for dashboard {dashboardId}: {describeErrorResponse(response)}") return None return response.json().get("sharesTo", []) def changeDashboardOwner(apiClient, dashboardId, newOwnerId, useAdminAccess, logger): """ Change who owns a dashboard - the same endpoint pysisense's own add_widget_script() helper uses to temporarily take ownership of a dashboard it doesn't otherwise have edit rights on. useAdminAccess should be True when taking ownership (the API user typically isn't already a party to the dashboard) and False when handing it back (the API user is the current owner at that point, so no admin override is needed). """ endpoint = f"/api/v1/dashboards/{dashboardId}/change_owner" if useAdminAccess: endpoint += "?adminAccess=true" response = apiClient.post(endpoint, data={"ownerId": newOwnerId, "originalOwnerRule": "edit"}) if not response or not response.ok: logger.error(f"Could not change the owner of dashboard {dashboardId} to {newOwnerId}: {describeErrorResponse(response)}") return False return True def restoreDashboardShares(apiClient, dashboardId, shares, logger): """Write a previously-captured share list (from getDashboardShares) back onto a dashboard, after a temporary ownership change.""" payload = { "sharesTo": [ { "shareId": share["shareId"], "type": share["type"], "rule": share.get("rule", "edit"), "subscribe": share.get("subscribe", False), } for share in shares ] } response = apiClient.post(f"/api/shares/dashboard/{dashboardId}", data=payload) if not response or not response.ok: logger.error(f"Could not restore the original shares for dashboard {dashboardId}: {describeErrorResponse(response)}") return False return True def withTemporaryOwnership(apiClient, dashboardId, apiUserId, logger, action): """ Make apiUserId the temporary owner of dashboardId, run action(), then restore the original owner and shares - even if action() raises, or the restore step itself fails partway. This is the use_temporary_ownership fallback (see config.example.yaml and README.md "Permissions"): Sisense can deny a widget write with 403 even to an admin token, when the token's user has no owner/edit share on that specific dashboard. Mirrors the ownership-swap-and-restore pattern pysisense's own add_widget_script() uses for widget scripts, applied here to widget field writes instead. Unlike that pysisense helper, restoration here always runs - including when action() raises or the write itself fails - so a dashboard is never left owned by the tool's API user because of a mid-run error. Returns (result, attempted): result is action()'s return value, or None if the swap could not be attempted at all (owner/shares lookup failed, or the ownership change itself failed - nothing was touched in that case). attempted is True whenever action() actually ran. """ originalOwnerId = getDashboardOwnerId(apiClient, dashboardId, logger) if not originalOwnerId: return None, False if originalOwnerId == apiUserId: # Already the owner - a plain write should not have failed with 403 # for ownership reasons, so there's nothing useful to swap here. return None, False originalShares = getDashboardShares(apiClient, dashboardId, logger) if originalShares is None: return None, False if not changeDashboardOwner(apiClient, dashboardId, apiUserId, True, logger): return None, False logger.warning(f"Temporarily changed the owner of dashboard {dashboardId} to the API user, to apply the font change.") try: result = action() finally: sharesRestored = restoreDashboardShares(apiClient, dashboardId, originalShares, logger) ownerRestored = changeDashboardOwner(apiClient, dashboardId, originalOwnerId, False, logger) if sharesRestored and ownerRestored: logger.warning(f"Restored the original owner and shares for dashboard {dashboardId}.") else: logger.error( f"Dashboard {dashboardId} may still be owned by the API user - restoring shares " f"{'succeeded' if sharesRestored else 'FAILED'} and restoring ownership " f"{'succeeded' if ownerRestored else 'FAILED'}. Check this dashboard's owner and " "shares in Sisense directly." ) return result, True def putWidgetWithOwnershipFallback(apiClient, dashboardId, widgetId, payload, useTemporaryOwnership, apiUserId, logger): """ Save a widget, retrying once through withTemporaryOwnership if the first attempt is denied with 403 and use_temporary_ownership is enabled. Returns (response, usedTemporaryOwnership) - response is whichever attempt actually produced one (None if the fallback couldn't even be attempted). """ response = apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload) deniedByPermissions = response is not None and response.status_code == 403 if not (deniedByPermissions and useTemporaryOwnership and apiUserId): return response, False logger.warning( f"Widget {widgetId} on dashboard {dashboardId} was denied (403) - retrying with " "temporary dashboard ownership since use_temporary_ownership is enabled." ) fallbackResponse, attempted = withTemporaryOwnership( apiClient, dashboardId, apiUserId, logger, lambda: apiClient.put(putWidgetEndpoint(dashboardId, widgetId), data=payload), ) if not attempted: # Swap couldn't even be attempted - report the original 403, not "no response". return response, False return fallbackResponse, True def fetchWidget(apiClient, dashboardId, widgetId, isAdmin, logger): """Fetch a widget's full current definition - the source of truth this tool reads from, diffs against, and eventually writes back to.""" response = apiClient.get(getWidgetEndpoint(dashboardId, widgetId, isAdmin)) if not response or not response.ok: logger.error(f"Could not fetch widget {widgetId} on dashboard {dashboardId} - skipping it. {describeErrorResponse(response)}") return None return response.json() def findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger): """ Scan dashboards for widgets matching widgetType, returning up to maxWidgets matches as a list of dicts with dashboard/widget IDs and titles. Pass maxWidgets=None to scan every dashboard with no cap. Stops scanning as soon as maxWidgets is reached, so a small run does not pay the cost of scanning every dashboard in the tenant. """ found = [] for dashboard in dashboards: if maxWidgets is not None and len(found) >= maxWidgets: break if isAdmin: # Admin export: works for dashboards not shared with this API user. response = apiClient.get( f"/api/v1/dashboards/export?dashboardIds={dashboard['oid']}&adminAccess=true" ) if not response or not response.ok: logger.warning( f"Could not export dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - " f"skipping. {describeErrorResponse(response)}" ) continue exportedDashboards = response.json() exported = exportedDashboards[0] if exportedDashboards else {} candidateWidgets = exported.get("widgets") or [] else: # Only dashboards this API user can already see. response = apiClient.get(f"/api/v1/dashboards/{dashboard['oid']}/widgets") if not response or not response.ok: logger.warning( f"Could not fetch widgets for dashboard {dashboard['oid']} ({dashboard.get('title', '')}) - " f"skipping. {describeErrorResponse(response)}" ) continue candidateWidgets = response.json() or [] for widget in candidateWidgets: if widget.get("type") != widgetType: continue found.append( { "dashboardId": dashboard["oid"], "dashboardTitle": dashboard.get("title", ""), "widgetId": widget["oid"], "widgetTitle": widget.get("title", ""), } ) if maxWidgets is not None and len(found) >= maxWidgets: break return found def undoChanges(apiClient, backupFile, logger, useTemporaryOwnership=False, apiUserId=None): """Restore every not-yet-undone widget in backupFile to its recorded state.""" records = loadBackup(backupFile) pending = [record for record in records if not record.get("undone")] if not pending: logger.info("Nothing to undo - no pending changes found in the backup file.") return logger.info(f"Reverting {len(pending)} previously applied change(s)...") # Revert most-recent-first, like a normal undo stack. for record in reversed(records): if record.get("undone"): continue # A hand-edited or corrupted backup file shouldn't be able to abort # the whole run - skip just this record and keep going. missingKeys = [key for key in ("dashboardId", "widgetId", "originalWidget") if key not in record] if missingKeys: logger.error(f"Skipping a backup record missing {', '.join(missingKeys)} - it cannot be undone.") continue dashboardId = record["dashboardId"] widgetId = record["widgetId"] originalWidget = record["originalWidget"] response, _ = putWidgetWithOwnershipFallback( apiClient, dashboardId, widgetId, buildPutPayload(originalWidget), useTemporaryOwnership, apiUserId, logger, ) if response and response.ok: record["undone"] = True record["undoneAt"] = datetime.now(timezone.utc).isoformat() logger.info(f"Reverted widget {widgetId} ({record.get('widgetTitle', '')}) on dashboard {dashboardId}.") else: logger.error(f"Failed to revert widget {widgetId}: {describeErrorResponse(response)}") # Save after every widget, not just at the end, so a partial undo run isn't lost. saveBackup(backupFile, records) logger.info("Undo complete.") def applyChanges( apiClient, candidates, selector, fontFamily, fontMode, apply, force, backupFile, isAdmin, quiet, verbose, logger, useTemporaryOwnership=False, apiUserId=None, ): """ Apply the configured font change(s) to each candidate widget. fontMode controls which of the two font-change mechanisms are used (see "Two ways to change the font" in README.md): - "css": add newRule to the front of style.currentCard.style - "config": set style.currentConfig.fontFamily to a single font name - "both": do both A widget is skipped only if every mechanism in use is already up to date for it; otherwise, only the mechanism(s) that actually need a change are applied (so, in "both" mode, a widget that already has the right CSS rule but the wrong font config only gets its font config updated). """ updateCss = fontMode in ("css", "both") updateConfig = fontMode in ("config", "both") newRule = buildCssRule(selector, fontFamily) if updateCss else None primaryFontName = extractPrimaryFontName(fontFamily) if updateConfig else None if updateCss: logger.info(f"CSS rule to apply: {newRule}") if updateConfig: logger.info(f"Font config value to apply (style.currentConfig.fontFamily): {primaryFontName!r}") backupRecords = loadBackup(backupFile) appliedCount = 0 skippedCount = 0 for candidate in candidates: # Re-fetch fresh rather than reuse the discovery payload, which has a # different shape (bulk export vs. single-widget document) and may # be stale. widget = fetchWidget(apiClient, candidate["dashboardId"], candidate["widgetId"], isAdmin, logger) if widget is None: continue # Read state for both mechanisms up front, even if only one is in # use, so the checks below don't need to branch on fontMode again. styleBlock = widget.get("style", {}) or {} currentCard = styleBlock.get("currentCard", {}) or {} currentConfig = styleBlock.get("currentConfig", {}) or {} existingStyle = currentCard.get("style", "") existingFontFamily = currentConfig.get("fontFamily", "") # A mechanism needs updating only if it's in use and not already # correct (or --force is set). This is what lets "both" mode touch # only the mechanism that's actually out of date on a given widget. needsCssChange = updateCss and (force or not ruleAlreadyApplied(existingStyle, newRule)) needsConfigChange = updateConfig and (force or existingFontFamily != primaryFontName) if not needsCssChange and not needsConfigChange: if not quiet: logger.info( f"Skipping widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) - " "already up to date. Use --force to re-apply anyway." ) skippedCount += 1 continue updatedStyle = mergeStyle(existingStyle, newRule) if needsCssChange else existingStyle # Dry run: log the preview, touch nothing. if not apply: if not quiet: previewLines = [ f"[DRY RUN] Would update widget {candidate['widgetId']} " f"({candidate['widgetTitle']!r} on dashboard {candidate['dashboardTitle']!r}):" ] if needsCssChange: previewLines.append(f" CSS style - old: {previewStyle(existingStyle, verbose)!r}") previewLines.append(f" CSS style - new: {previewStyle(updatedStyle, verbose)!r}") if needsConfigChange: previewLines.append(f" Font config - old: {existingFontFamily!r}") previewLines.append(f" Font config - new: {primaryFontName!r}") logger.info("\n".join(previewLines)) continue # Back up before mutating, and save immediately so --undo survives an # interrupted run. deepcopy is required, not optional: `widget` is # mutated in place below, and saveBackup() re-serializes this whole # list on every call - a bare reference here would let a later # widget's save silently overwrite this "before" snapshot with # whatever `widget` looks like by then. backupRecords.append( { "timestamp": datetime.now(timezone.utc).isoformat(), "dashboardId": candidate["dashboardId"], "widgetId": candidate["widgetId"], "widgetTitle": candidate["widgetTitle"], "fontModeUsed": fontMode, "selectorUsed": selector if needsCssChange else None, "fontUsed": fontFamily, "originalWidget": copy.deepcopy(widget), "undone": False, } ) saveBackup(backupFile, backupRecords) # Mutate only what's out of date - a widget already correct for one # mechanism keeps that field untouched, even in "both" mode. if needsCssChange: widget.setdefault("style", {}).setdefault("currentCard", {})["style"] = updatedStyle if needsConfigChange: widget.setdefault("style", {}).setdefault("currentConfig", {})["fontFamily"] = primaryFontName response, usedTemporaryOwnership = putWidgetWithOwnershipFallback( apiClient, candidate["dashboardId"], candidate["widgetId"], buildPutPayload(widget), useTemporaryOwnership, apiUserId, logger, ) if response and response.ok: appliedCount += 1 if usedTemporaryOwnership: backupRecords[-1]["usedTemporaryOwnership"] = True saveBackup(backupFile, backupRecords) if not quiet: # Report exactly which mechanism(s) were touched for this # widget, since in "both" mode it may only have been one. changedParts = [ label for label, changed in (("CSS style", needsCssChange), ("font config", needsConfigChange)) if changed ] viaOwnershipSwap = " (via temporary dashboard ownership)" if usedTemporaryOwnership else "" logger.info( f"Updated widget {candidate['widgetId']} ({candidate['widgetTitle']!r}) " f"on dashboard {candidate['dashboardId']} - changed: {', '.join(changedParts)}{viaOwnershipSwap}." ) else: logger.error(f"Failed to update widget {candidate['widgetId']}: {describeErrorResponse(response)}") backupRecords[-1]["updateFailed"] = True saveBackup(backupFile, backupRecords) return appliedCount, skippedCount def parseArgs(): """Define and parse the tool's command-line flags.""" parser = argparse.ArgumentParser( description="Change the font used by BloX widgets on a Sisense instance.", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--config", dest="configPath", default=defaultConfigPath, metavar="PATH", help="Path to config.yaml.") # Font settings default to None, not a real value, so main() can tell # whether each was actually passed - see resolveSetting(). parser.add_argument( "--selector", dest="selector", default=None, metavar="CSS_SELECTOR", help="Override default_selector from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackSelector!r}.", ) parser.add_argument( "--font", dest="fontFamily", default=None, metavar="FONT_FAMILY", help="Override default_font_family from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackFontFamily!r}.", ) parser.add_argument( "--font-mode", dest="fontMode", choices=fontModeChoices, default=None, help=( "Override default_font_mode from config.yaml, for this run only - normally set " f"in config.yaml instead. Used if neither is set: {fallbackFontMode!r}. " "'css' adds a CSS rule that directly targets nearly every element, including custom " "HTML and input fields. 'config' sets Sisense's simpler built-in font setting instead, " "which some widgets' own elements (including input fields) may not pick up. Neither is " "guaranteed to cover everything - BloX templates vary - so 'both' applies both changes " "for the best chance of full coverage." ), ) parser.add_argument( "--max-widgets", dest="maxWidgets", type=int, default=None, metavar="N", help="Override default_max_widgets from config.yaml, for this run only. Normally set " f"in config.yaml instead. Used if neither is set: {fallbackMaxWidgets}. " "0 or a negative number means no limit - same as --all.", ) parser.add_argument( "--all", dest="allWidgets", action="store_true", help="Process every matching widget, with no limit. Overrides --max-widgets.", ) parser.add_argument( "--dashboard-id", dest="dashboardId", default=None, metavar="ID", help="Target one already-known widget directly by dashboard and widget id, instead of " "scanning the tenant. Must be used together with --widget-id. --max-widgets/--all are " "ignored in this mode.", ) parser.add_argument( "--widget-id", dest="widgetId", default=None, metavar="ID", help="Used together with --dashboard-id. See --dashboard-id.", ) # Run control - what this particular invocation actually does. parser.add_argument( "--apply", dest="apply", action="store_true", help="Actually write changes to Sisense. Without this flag, the tool only previews what it would do.", ) parser.add_argument( "--force", dest="force", action="store_true", help="Re-apply even to widgets that already look up to date for the selected --font-mode.", ) parser.add_argument( "--undo", dest="undo", action="store_true", help="Revert every not-yet-undone change recorded in --backup-file, then exit.", ) # Where state is read from / written to. parser.add_argument("--backup-file", dest="backupFile", default=defaultBackupFile, metavar="PATH", help="Where change backups are stored.") parser.add_argument("--log-file", dest="logFile", default=defaultLogFile, metavar="PATH", help="Where the run log is written.") # Output verbosity. parser.add_argument( "--quiet", dest="quiet", action="store_true", help="Only log the scan summary and any warnings/errors - skip the per-widget lines. " "Useful for large runs.", ) parser.add_argument( "--verbose", dest="verbose", action="store_true", help="Show full style strings in the log instead of a truncated preview.", ) return parser.parse_args() def loadConfig(configPath): """Read and sanity-check config.yaml, exiting with a plain-language explanation (not a Python traceback) for the mistakes a new user is most likely to make: no config file yet, broken YAML, or placeholder values left unfilled.""" if not os.path.exists(configPath): sys.exit( f"Could not find {configPath}.\n\n" "On first use, copy the example config and fill in the required values:\n\n" " cp config.example.yaml config.yaml\n\n" "Then edit config.yaml with the Sisense domain and API token to use - see README.md." ) if not os.path.isfile(configPath): sys.exit(f"{configPath} is not a file (is it a directory?). Point --config at a config.yaml file.") with open(configPath, "r") as configFile: try: config = yaml.safe_load(configFile) or {} except yaml.YAMLError as error: sys.exit(f"{configPath} is not valid YAML: {error}\n\nCheck for typos, especially around quotes and indentation.") if not isinstance(config, dict): sys.exit(f"{configPath} must contain key: value settings, not a {type(config).__name__}. See config.example.yaml.") placeholderValues = {"", "your-tenant.sisense.com", "YOUR_SISENSE_API_TOKEN"} missingKeys = [key for key in ("domain", "token") if str(config.get(key, "")).strip() in placeholderValues] if missingKeys: sys.exit( f"{configPath} still has placeholder value(s) for: {', '.join(missingKeys)}.\n\n" "Edit config.yaml with the real Sisense domain and API token to use - see README.md." ) return config def resolveSetting(cliValue, config, configKey, fallback): """ Decide the effective value for a setting that can be given in three places, checked in this order: a command-line flag, a value in config.yaml, or this tool's own built-in fallback. """ if cliValue is not None: return cliValue configValue = config.get(configKey) if configValue is not None: return configValue return fallback def requireNonEmptyString(value, settingDescription): """Exit with a clear error if value isn't a usable non-empty string - catches mistakes like an empty --selector/--font flag or a default_font_family: "" in config.yaml, either of which would otherwise silently produce a broken CSS rule or font name later on.""" if not isinstance(value, str) or not value.strip(): sys.exit(f"Invalid {settingDescription}: {value!r} - must be a non-empty string.") def parseWholeNumber(value): """ Interpret value as a whole number, or return None if it isn't one. Used for default_max_widgets, which - unlike --max-widgets - isn't type-checked by argparse and can be any YAML value. Deliberately stricter than Python's own int(): int(10.5) truncates to 10 and int(True) succeeds as 1, both of which would silently accept a config mistake instead of rejecting it. """ if isinstance(value, bool): return None if isinstance(value, int): return value if isinstance(value, float): return int(value) if value.is_integer() else None if isinstance(value, str): try: return int(value.strip()) except ValueError: return None return None def main(): """ Entry point: parse arguments, load configuration, then either undo a previous run or scan for candidate widgets and apply font changes to them. This function only wires the pieces together - all the actual logic lives in the functions it calls. """ # Logging starts as early as possible so even a bad --log-file value or a # config problem below gets written to the console consistently. args = parseArgs() logger = setupLogger(args.logFile) # config.yaml supplies the Sisense connection details (read directly by # SisenseClient) plus the default_* settings resolved below. config = loadConfig(args.configPath) isAdmin = bool(config.get("is_user_admin")) widgetType = config.get("widget_type", "BloX") # use_temporary_ownership (see config.example.yaml and README.md # "Permissions") needs the API user's own id, read directly out of the # token rather than via a separate lookup call. useTemporaryOwnership = bool(config.get("use_temporary_ownership")) apiUserId = decodeJwtUserId(config.get("token", "")) if useTemporaryOwnership else None if useTemporaryOwnership and not apiUserId: logger.warning( "use_temporary_ownership is enabled in config.yaml, but the API user's id could not be " "read from token - this fallback will be skipped for the rest of this run." ) apiClient = SisenseClient(config_file=args.configPath, debug=False) # --undo works entirely from the backup file and skips everything below. if args.undo: undoChanges(apiClient, args.backupFile, logger, useTemporaryOwnership, apiUserId) return # Priority: CLI flag, then config.yaml, then built-in fallback - see # resolveSetting(). selector = resolveSetting(args.selector, config, "default_selector", fallbackSelector) requireNonEmptyString(selector, "selector (--selector / default_selector)") fontFamily = resolveSetting(args.fontFamily, config, "default_font_family", fallbackFontFamily) requireNonEmptyString(fontFamily, "font (--font / default_font_family)") fontMode = resolveSetting(args.fontMode, config, "default_font_mode", fallbackFontMode) if fontMode not in fontModeChoices: # Only reachable via a typo in config.yaml's default_font_mode - # argparse itself already restricts --font-mode to fontModeChoices. sys.exit( f"Invalid default_font_mode in config.yaml: {fontMode!r}. Must be one of: " f"{', '.join(fontModeChoices)}." ) # default_max_widgets isn't type-checked by argparse the way --max-widgets # is (it can only come from config.yaml here), so it needs its own # validation before being used as a number. rawMaxWidgets = resolveSetting(args.maxWidgets, config, "default_max_widgets", fallbackMaxWidgets) maxWidgetsSetting = parseWholeNumber(rawMaxWidgets) if maxWidgetsSetting is None: sys.exit(f"Invalid default_max_widgets in config.yaml: {rawMaxWidgets!r} - must be a whole number.") # --all, or a zero/negative limit from either source, both mean "no cap". maxWidgets = None if (args.allWidgets or maxWidgetsSetting <= 0) else maxWidgetsSetting if bool(args.dashboardId) != bool(args.widgetId): sys.exit("--dashboard-id and --widget-id must be used together.") if args.dashboardId and args.widgetId: # Skip the tenant scan entirely and go straight to one already-known # widget - useful for retrying, or verifying, a specific widget # without depending on dashboard discovery order (which can surface # unrelated widgets, or the same widget twice, on a tenant whose # dashboards have data quirks of their own). widget = fetchWidget(apiClient, args.dashboardId, args.widgetId, isAdmin, logger) if widget is None: sys.exit(f"Could not fetch widget {args.widgetId} on dashboard {args.dashboardId} - see the log above.") if widget.get("type") != widgetType: sys.exit( f"Widget {args.widgetId} is a {widget.get('type')!r} widget, not {widgetType!r} - refusing to " "change its font. Pass the id of a BloX widget instead." ) candidates = [ { "dashboardId": args.dashboardId, "dashboardTitle": "", "widgetId": args.widgetId, "widgetTitle": widget.get("title", ""), } ] logger.info(f"Targeting widget {args.widgetId} ({widget.get('title', '')!r}) directly - skipping the tenant scan.") else: dashboardHelper = Dashboard(api_client=apiClient) dashboards = dashboardHelper.get_all_dashboards() # On failure (unreachable domain, bad token, server error), pysisense # returns {"error": "..."} instead of a list - catch that here so it # surfaces as a clear message instead of a confusing crash further down. if not isinstance(dashboards, list): detail = dashboards.get("error", dashboards) if isinstance(dashboards, dict) else dashboards sys.exit( f"Could not retrieve dashboards from Sisense: {detail}\n\n" "Check that domain and token in config.yaml are correct, and that the server is reachable." ) scopeDescription = "every" if maxWidgets is None else f"up to {maxWidgets}" logger.info(f"Scanning {len(dashboards)} dashboard(s) for {scopeDescription} '{widgetType}' widget(s)...") candidates = findTargetWidgets(apiClient, dashboards, widgetType, isAdmin, maxWidgets, logger) logger.info(f"Found {len(candidates)} candidate widget(s).") # With args.apply False, this only previews - see applyChanges(). appliedCount, skippedCount = applyChanges( apiClient, candidates, selector, fontFamily, fontMode, args.apply, args.force, args.backupFile, isAdmin, args.quiet, args.verbose, logger, useTemporaryOwnership, apiUserId, ) logger.info(f"Done. Applied: {appliedCount}, skipped (already applied): {skippedCount}, candidates seen: {len(candidates)}.") # Surface the exact undo command after a real run, so it doesn't have to # be looked up in --help right when it might be needed. if not args.apply: logger.info("This was a dry run - nothing was written. Re-run with --apply to write changes.") elif appliedCount: logger.info(f"To revert everything just written, run: python {os.path.basename(__file__)} --undo --backup-file {args.backupFile}") if __name__ == "__main__": try: main() except KeyboardInterrupt: sys.exit("\nStopped.")

      Two Blox Widget's with different font families, and font colors
      Disclaimer: This post outlines a potential custom workaround for a specific use case or provides instructions regarding a specific task. The solution may not work in all scenarios or Sisense versions, so we strongly recommend testing it in your environment before deployment. If you need further assistance with this, please let us know.

      Jeremy Friedel
      Jeremy FriedelPosted 1 week ago
      0
               
      • BloxChevronRightIcon

      BloX Template: Indicators With Sparkline

               

      Introduction In many use cases, the most current state of your KPI is as important as the trend of that KPI over time (for example, the trend of stock price, impressions or reach of your social media campaigns, etc), so you want to show both an indicator and a sparkline chart next to it. Creating multiple indicator and line chart widgets is not the most efficient way to implement this requirement considering the performance (multiple widgets and queries) and the complexity of the UI design (fonts size, layout etc). The Indicators with Sparklines template allows you to implement this requirement easily using BloX. This template is structured in 3 columns, one for the sparkline (shows history of the value), one for the indicator (shows the current value), and one for the name of the KPI. Installation Instructions 1. Download the template . Change the extension from txt to json 2. Import the template and choose the downloaded file. 3. Refresh your browser. 4. Create a new Blox Widget and open the Templates menu under the design panel. You should now be able to see and choose the new template. If you cannot see the template in the All Templates menu, please restart the Sisense.Blox service on your Sisense server. 5. Populate the Items and Values panels with relevant fields and aggregations. Please note that any change in panel item names should be reflected in the card editor. In  Items , add the date column. In Values , add two columns for each KPI: one column for the unfiltered KPI to show the trendline, e.g. SUM(Impressions) , and another column for the current value of the KPI using measured value, e.g. ( SUM(Impressions), Date) where Date is set to Today. Technical Notes CAROUSEL & CAROUSEL BUTTON The "showCarousel" parameter should always be set to True, otherwise, the entire block will be repeated vertically for all the different dates in the data (all the numbers will be exactly the same). To remove the carousel button, please use this widget script below. widget.on('ready', function(sender, ev){ //remove carousel button $('.carousel-button', element).remove(); }); COLORS To modify the color of the sparkline, search for this piece of code in the Editor. You can then modify the line-color, fill-color, and point-color. You can also modify other parameters such as the width of the graph. <span class='blox-sparkline' type='line' line-color='#3a5694' width='220' height='40' line-width='3' fill-color='#c6d8f8' point-color:'#28467a'>{spark:Organic}</span> To change the color of the fonts, search for this piece of code in the Editor. You can then modify the color paramater. "style": { "color": "#395795" } PDF EXPORT This template works with PDF Export. To hide the carousel arrow buttons: place below the "showCarousel": true, "carouselAnimation": { "showButtons": false },

      intapiuser
      intapiuserPosted 3 years ago • Last reply 10 months ago
      6
               
      • BloxChevronRightIcon

      Using BloX To Dynamically Change A Dimension

               

      The goal is to allow users to dynamically choose a dimension. The below script will work with a table or a pivot table, but it can be modified to work with other widget types as well.  Step 1 Create a BloX widget with the below script. It will consist of a simple drop down input and an action button. { "style": "", "script": "", "title": "", "showCarousel": true, "carouselAnimation": { "delay": 0, "showButtons": false }, "body": [ { "type": "Container", "items": [ { "type": "TextBlock", "id": "", "class": "", "text": "Choices", "style": { "margin-left": "100px", "margin-right": "100px" } }, { "type": "Input.ChoiceSet", "id": "dropdownVal", "class": "", "displayType": "compact", "value": "1", "choices": "{choices:colName}", "style": { "margin-left": "100px", "margin-right": "100px" } } ] } ], "actions": [ { "type": "Modify Pivot", "title": "Change Dimension" } ] } Please note: In the above example, the drop down choices are coming from the field 'ColName.' The values in ColName exactly match the target field names in the ElastiCube. The drop down choices could be input manually, but they must exactly match the target field names in the ElastiCube.  Step 2 Create a table, or pivot table. In this example, I have added two columns. The title in the example is 'Test Widget Title.' Step 3 Using the console, determine the widget index and target panel. First, open the developer console and type: prism.activeDashboard.widgets.$$widgets You will see a list of all widgets on a dashboard. Look for the correct title or widget type. Note the widget index (it is 6, in this example).  Second, navigate to: prism.activeDashboard.widgets.$$widgets[6].metadata.panels There, note the index of the panel we would like to modify. In this example, we want to modify a column, that is the first object in panels (index = 0). Finally, open 'Items.' Each item is a field that was added to the columns panel in the target widget. Note the index of the field we want to change. In the example, we want to change the second column (index = 1). Step 4 Create a custom BloX action to target the correct panel item and change it to the user input value. Use the below script for the action:  //Variable that stores the user's input var dropdownInput = payload.data.dropdownVal; //Variable that stores the target widget var widget = prism.activeDashboard.widgets.$$widgets[6]; widget.metadata.panels[0].items[1].field.id = "[Exports." + dropdownInput + "]"; widget.metadata.panels[0].items[1].jaql.dim = "[Exports." + dropdownInput + "]"; widget.metadata.panels[0].items[1].jaql.title = dropdownInput; widget.metadata.panels[0].items[1].jaql.column = dropdownInput; //Refresh widget widget.refresh(); The following lines will need to match the incidences from step three. var widget = prism.activeDashboard.widgets.$$widgets[6]; widget.metadata.panels[0].items[1].x You can see the above custom action changes four things about the panel item. Please note: the below lines must match the table name where the field resides. Replace it with the appropriate table name: "[Exports." That's it! Apply all changes and the drop down filter should change the field in the second column of the table!    If you wish to change a dimension in a different chart, be sure to look for the correct panel in step 3. 

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      6
               
      • BloxChevronRightIcon

      Change Date Granularity of a Widget using BloX

               

      This article will cover the below options: Section 1- Buttons + Highlight Background-Color - Creating Buttons of date granularity slicing options (Alternative for Tabber plugin) OR Section 2 - Dropdown List - Creating Drop-down list with Date granularity slicing options (Alternative for Tabber plugin) ------------------------------------------------------------------------ Section 1- Buttons + Highlight Background-Color:  You can follow the steps below or download the dashboard file , import it and create a custom action as described in Step 2. ( Buttons-DateGranularityBloX.dash ) Step 1:  Create a BloX widget and use this snippet in the Editor tab: (or import this JSON file as a BloX template) Provide the widget ID to apply the change, you can apply on multiple widgets : "widgetToModify": [" 5f7b91e27051052128453cdf "," 5f7b91e27051052128sdfcer "] "style": "", "script": "", "title": "", "titleStyle": [ { "display": "none" } ], "showCarousel": true, "body": [], "actions": [ { "type": "dynamicGranSwap", "title": "Years", "style": { "background-color": "#1D426C" }, "data": { "widgetToModify": [ "5f7b91e27051052128453cdf" ] }, "dategran": "years" }, { "type": "dynamicGranSwap", "title": "Quarters", "style": { "background-color": "#D3D3D3" }, "data": { "widgetToModify": [ "5f7b91e27051052128453cdf" ] }, "dategran": "quarters" }, { "type": "dynamicGranSwap", "title": "Months", "style": { "background-color": "#D3D3D3" }, "data": { "widgetToModify": [ "5f7b91e27051052128453cdf" ] }, "dategran": "months" }, { "type": "dynamicGranSwap", "title": "Weeks", "style": { "background-color": "#D3D3D3" }, "data": { "widgetToModify": [ "5f7b91e27051052128453cdf" ] }, "dategran": "weeks" }, { "type": "dynamicGranSwap", "title": "Days", "style": { "background-color": "#D3D3D3" }, "data": { "widgetToModify": [ "5f7b91e27051052128453cdf" ] }, "dategran": "days" }, { "type": "dynamicGranSwap", "title": "Hours", "style": { "background-color": "#D3D3D3" }, "data": { "widgetToModify": [ "5f7b91e27051052128453cdf" ] }, "dategran": "minutes" } ] } Step 2:    Create a custom action with the below code and name it " dynamicGranSwap ": Adjust the background color of unselected & selected buttons // Holds the chosen granularity from the selected button 'months' for example const dategran = payload.dategran; var widgetIds = payload.data.widgetToModify; //Change the background color for unselected buttons payload.widget.style.currentCard.actions.forEach(function(i){ i.style["background-color"] = '#D3D3D3' }) //Change the background color for selected buttons payload.widget.style.currentCard.actions .filter(i => i.dategran == dategran)[0].style["background-color"] = "#1D426C" //Redraw the changes payload.widget.redraw() //For each widget change the data granularity payload.widget.dashboard.widgets.$$widgets .filter(i=>widgetIds.includes(i.oid)) .forEach(function(widget){ //change the level of granularity to the chosen value from our button: 'months' for example widget.metadata.panels[0].items[0].jaql.level = dategran; //Apply changes to Mongo widget.changesMade() //Refresh widget widget.refresh() }) Section 2 - Dropdown List: You can follow the steps below or download the dashboard file  , import it and create a custom action as described in Step 2. ( DateGrnularityBloX.dash ) Step 1:   Create a  BloX widget and use this snippet to create the choice-set: { "type": "Input.ChoiceSet", "id": "data.choicesetvalue", "class": "", "displayType": "compact", "value": "1", "choices": [ { "title": "Hours", "value": "minutes" }, { "title": "Days", "value": "days" }, { "title": "Weeks", "value": "weeks" }, { "title": "Months", "value": "months" }, { "title": "Quarters", "value": "quarters" }, { "title": "Years", "value": "years" } ] } Step 2:   Create a custom action with the below code and name it "DateGranDrill" (provide the widget ID to apply the change - can apply on multiple widgets): var gran = payload.data.choicesetvalue // Holds the chosen granularity 'months' for example var widgets = ['5ec291c181203611649756f6','5ec291c121803611649756c7'] //Apply this action on widget ids //for each widget id widgets.forEach(myfunction) function myfunction (item) { var widgetfindid = prism.activeDashboard.widgets.$$widgets.find(w => w.oid === item) widgetfindid.metadata.panels[0].items[0].jaql.level = gran //change the level of granularity to the chosen value 'months' for example or the item location (currently 0) widgetfindid.changesMade() //apply changes to Mongo widgetfindid.refresh() //refresh the widget } Step 3:   Use the action you've created in the editor under "actions" section:  "actions": [ { "type": "DateGranDrill", "title": "Apply" } ]

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      8
               
      • BloxChevronRightIcon

      BloX Template: Dynamic IFrame

               

      Introduction: The Dynamic iFrame template  contains an iFrame element whose hosted page's URL is set by data, fetched from the Elasticube, or contains it. Since the URL is data-related, the content of the iFrame would be sensitive to filters. Please note: It is recommended to use this template with single-selection filters, or with a limited number of possible items.   Installation Instructions: 1. Download the  attached template. 2. Extract the json file into your BloX template directory:   C:\Program Files\Sisense\app\blox-service\resources\templates    Note: If a template of the same name already exists, make sure to rename the file name. 3. Refresh your browser. 4. Create a new Blox Widget and open the Templates menu under the design pane. You should now be able to see and choose the new template.   If you cannot see the template in the All Templates menu, please restart the Sisense.blox service on your Sisense server. 5. Populate the Items panel with the relevant fields that contain the URL, or its components. When adjusting the template to yout use case, please note: Any change in panel item names should be reflected in the card editor. The suggested source URL consists of a fixed part and a dynamic part. You can use this approach to refer to different entities within the same base URL. In our example, these are different pages within Wikipedia, but those can also be different accounts in Salesforce or different tickets in Zendesk, provided the appropriate id's.   If the field contains the full URL, you may use it alone in the iFrame's SRC attribute:

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      3
               
      • BloxChevronRightIcon

      Blox Dropdown Placeholder

               

      By default, BloX will use the first element in your dropdown as the default text in the dropdown box. Instead of this value, this method adds a different default non-selectable value. Steps For Implementation Add the addPlaceholder class to your Input.ChoiceSet Add the following script to the script of your BloX widget $('.addPlaceholder').prepend('<option value=\"\" disabled selected>Select Filter</option>') You can update the text "Select Filter" in the script above to customize the placeholder text.

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      6
               
      • BloxChevronRightIcon

      Changing Measures In The Entire Dashboard Using Blox User Interface

                               

      Objective Display several related values (KPIs/Measures) in multiple widgets in the dashboard based on the user selection, in a simple and easy way, without duplicating widgets/formulas or tables in the Elasticube. As a direct result - the queries behind the widgets are extremely faster - lead to shorter loading time when the end-user needs to change the whole dashboard KPIs.   Example files (works with Ecommerce Sample Elasticube): Dashboard Blox Template Blox Action Demonstration: Preparation Create a new Indicator widget and save its WidgetId. Create a custom action with this code below and name it SwitchMeasure var dimIndex = payload.data.selectVal - 1; var dimToSwapTo = payload.widget.metadata.panels[1].items[dimIndex].jaql; var widgetIds = payload.data.widgetToModify; payload.widget.dashboard.widgets.$$widgets .filter(i=>widgetIds.includes(i.oid)) .forEach(function(widget){ if(widget.metadata.panels[1].$$widget.type == 'indicator') { widget.metadata.panels[0].items[0].jaql = dimToSwapTo; } else { widget.metadata.panels[1].items[0].jaql = dimToSwapTo; } widget.changesMade(); widget.refresh(); }) Implementation 1. ADDING MEASURES TO SWITCH BETWEEN The formulas in the values panel define the measures your other widgets will switch between. Create a new Blox widget and add all the formulas ( Up to 20 Formulas ) you want to switch between into the values panel in the selection of the button Blox widget. Please pay attention to the order (top value to bottom -> left to right buttons) 2. CUSTOMIZING THE BUTTONS SELECTION WIDGET This step will define the values that are visible to the user in the button selection widget . -Add choices that reflect the ORDER and intuitive name the formulas in the values panel. -Add the widget IDs of the widgets you'd like to modify to the script of each option. You can find the widget ids in the URL when in widget editing mode. -Change the button color by editing the Background color for each option. For example, we will be switching between Revenue, Cost, and Quantity, our Blox script would be: { "style": "", "script": "", "title": "", "showCarousel": true, "titleStyle": [ { "display": "none" } ], "carouselAnimation": { "delay": 0, "showButtons": false }, "body": [ { "type": "TextBlock", "id": "", "class": "", "text": "‎‎‏‏‎ ‎" } ], "actions": [ { "type": "SwitchMeasure", "title": "Revenue", "style": { "backgroundColor": "#298C1A" }, "data": { "widgetToModify": [ "5f1b462e336d9c242cb9e8ea", "5f1b462e336d9c242cb9e8e5", "5f1b462e336d9c242cb9e8e7", "5f1b462e336d9c242cb9e8e9", "5f1b462e336d9c242cb9e8e8" ], "selectVal": "1" } }, { "type": "SwitchMeasure", "title": "Cost", "style": { "backgroundColor": "#A31818" }, "data": { "widgetToModify": [ "5f1b462e336d9c242cb9e8ea", "5f1b462e336d9c242cb9e8e5", "5f1b462e336d9c242cb9e8e7", "5f1b462e336d9c242cb9e8e9", "5f1b462e336d9c242cb9e8e8" ], "selectVal": "2" } }, { "type": "SwitchMeasure", "title": "Quantity", "style": { "backgroundColor": "#708AA5" }, "data": { "widgetToModify": [ "5f1b462e336d9c242cb9e8ea", "5f1b462e336d9c242cb9e8e5", "5f1b462e336d9c242cb9e8e7", "5f1b462e336d9c242cb9e8e9", "5f1b462e336d9c242cb9e8e8" ], "selectVal": "3" } } ] } 3. CUSTOMIZING THE BUTTON SELECTION ANIMATION Add the script (widget script) below to the indicator you created in the preparation section: Change the WidgetID to your indicator Widget ID Change the formula names  based on the value list from the buttons selection widget Change the button’s titles  based on the titles you selected the buttons selection widget //Widget Script: var ChooseYourUnselectColor = '#D3D3D3'; var Button1Color = '#298C1A'; var Button2Color = '#A31818'; var Button3Color = '#708AA5'; var widgetIndicator = '5f1b462e336d9c242cb9e8ea' widget.on('ready',function(widget, args){ var widgetOID = widgetIndicator; //Get the selected KPI object var widget = prism.activeDashboard.widgets.$$widgets.find(w => w.oid === widgetOID) if(widget.metadata.panels[0].items[0].jaql.title == 'Total Revenue'){ var textOfButtonToFormat1 = 'Cost'; var textOfButtonToFormat2 = 'Quantity'; var selectedButton = 'Revenue' $('button.btn:contains('+textOfButtonToFormat1+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": ChooseYourUnselectColor }); $('button.btn:contains('+textOfButtonToFormat2+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": ChooseYourUnselectColor }); $('button.btn:contains('+selectedButton+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": Button1Color }); } else if (widget.metadata.panels[0].items[0].jaql.title == 'Total Cost') { var textOfButtonToFormat1 = 'Revenue'; var textOfButtonToFormat2 = 'Quantity'; var selectedButton = 'Cost' $('button.btn:contains('+textOfButtonToFormat1+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": ChooseYourUnselectColor }); $('button.btn:contains('+textOfButtonToFormat2+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": ChooseYourUnselectColor }); $('button.btn:contains('+selectedButton+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": Button2Color }); } else { var textOfButtonToFormat1 = 'Revenue'; var textOfButtonToFormat2 = 'Cost'; var selectedButton = 'Quantity' $('button.btn:contains('+textOfButtonToFormat1+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": ChooseYourUnselectColor }); $('button.btn:contains('+textOfButtonToFormat2+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": ChooseYourUnselectColor }); $('button.btn:contains('+selectedButton+')').css({ transition : 'background-color 50ms ease-in-out', "background-color": Button3Color }); } })

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      9
               
      • BloxChevronRightIcon

      BloX - Dropdown list as a dashboard filter

               

      Introduction: The below article will provide a BloX template that will help you create a dropdown filter   The ‘Dynamic Dropdown’ snippet allows you to populate your dropdown list with a list of values from a specific field (for example, list of Brands). The structure of the attached template is 3 columns, with 3 containers (in order to place the 3 components in one row, and not one below the other). Instructions: Create a JSON file with the code provided below Open your dashboard and add the dashboard filter you would like to affect Open a BloX widget. Click on the menu icon -> Import template, then choose the created file - Refresh your browser Create a new BloX widget and choose the imported template – Add the field you would like to filter by under 'Items' section Widget's editor section: Replace the "Brand" in: "choices" with the exact field name you have placed in the panel in order to get the list of values (you can use ctrl+f to find it). For example: "{choices:Brand}" with" {choices:My_Field}" Replace the "Brand" of "filterName": "Brand" with the exact name of the dashboard filter you have added and want to affect Optional: Go to the widget's 'Filters' tab and toggle off the "dashboard filters" so it will not affect this widget (this way you will be able to see the entire list of values anytime) If you would like to display a specific value as a default one (instead of the first value in your list), you can write the exact name of the value here –     10. Please note that the above ID field ("id": "data.filters[0].filterJaql.members[0]") relates to the below        action path -       CAROUSEL In order to show the widget’s components only once, and avoiding a stacked view according to our number of values, we are showing the carousel but hiding the arrows -   Additional Configuration: If you want BloX widget to be populated with the current filter value, please try to use the script below. You need to update filter index if it is not the first filter on the dashboard in … dashboard.filters.$$items[x].   widget.on('ready', ()=>{ $(`widget[widgetid="${widget.oid}"]`).find('select').val(widget.dashboard.filters.$$items[0].jaql.filter.members[0]) })   DynamicChoiceDropDown.json: { "card": { "style": {}, "script": "", "title": "", "titleStyle": [ { "display": "none" } ], "showCarousel": true, "carouselAnimation": { "showButtons": false }, "body": [ { "type": "Container" }, { "type": "ColumnSet", "separator": false, "spacing": "default", "columns": [ { "type": "Column", "width": "170px", "items": [ { "type": "TextBlock", "size": "small", "weight": "regular", "wrap": true, "text": "Choose a Brand", "style": { "color": "black", "padding-left": "10px", "margin-left": "10px", "backgroundColor": "white" } } ] }, { "type": "Column", "spacing": "none", "width": "175px", "items": [ { "type": "Container", "spacing": "none", "width": "150px", "items": [ { "type": "Input.ChoiceSet", "id": "data.filters[0].filterJaql.members[0]", "class": "", "value": "Reseller", "displayType": "compact", "choices": "{choices:Brand}" } ] } ] }, { "type": "Column", "spacing": "none", "width": "175px", "items": [ { "type": "Container", "spacing": "none", "width": "80px", "items": [ { "type": "ActionSet", "margin": "0px", "padding": "0px", "actions": [ { "type": "Filters", "title": "Apply", "data": { "filters": [ { "filterName": "Brand", "filterJaql": { "explicit": true, "members": [ "" ] } } ] } } ] } ] } ] } ] } ], "actions": [] }, "config": { "fontFamily": "Open Sans", "fontSizes": { "default": 14, "small": 16, "medium": 20, "large": 50, "extraLarge": 32 }, "fontWeights": { "default": 500, "light": 100, "bold": 1000 }, "containerStyles": { "default": { "backgroundColor": "#FFFFFF", "foregroundColors": { "default": { "normal": "#3a4356" }, "white": { "normal": "#ffffff" }, "grey": { "normal": "#9ea2ab" }, "orange": { "normal": "#f2B900" }, "yellow": { "normal": "#ffcb05" }, "black": { "normal": "#000000" }, "lightGreen": { "normal": "#3ADCCA" }, "green": { "normal": "#54a254" }, "red": { "normal": "#dd1111" }, "accent": { "normal": "#2E89FC" }, "good": { "normal": "#54a254" }, "warning": { "normal": "#e69500" }, "attention": { "normal": "#cc3300" } } } }, "imageSizes": { "default": 40, "small": 40, "medium": 80, "large": 160 }, "imageSet": { "imageSize": "medium", "maxImageHeight": 100 }, "actions": { "color": "white", "backgroundColor": "", "maxActions": 5, "spacing": "none", "buttonSpacing": 20, "actionsOrientation": "horizontal", "actionAlignment": "left", "showCard": { "actionMode": "inline", "inlineTopMargin": 16, "style": "default" } }, "spacing": { "default": 5, "none": 0, "small": 20, "medium": 60, "large": 20, "extraLarge": 40, "padding": 0 }, "separator": { "lineThickness": 3, "lineColor": "#ffcb05" }, "factSet": { "title": { "size": "default", "color": "default", "weight": "bold", "warp": true }, "value": { "size": "default", "color": "default", "weight": "default", "warp": true }, "spacing": 20 }, "supportsInteractivity": true, "height": 49, "imageBaseUrl": "" } }

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      6
               
      • BloxChevronRightIcon

      BloX Template - Search box to filter 2 dimensions

                               

      BloX Template - Search box to filter 2 dimensions  This article provides a BloX template for the use case when you need a search box to be able to filter 2 dimensions at once. This solution is based on a custom action developed. See the guide for reference:   Creating Custom Actions .   1. Import a Blox template attached.  2. Create a new custom action called "filterByDimensions" with the code below:    const searchInput = document.getElementById('bloxFiltersInput').getAttribute('value') if (searchInput) { payload.data.filters.forEach(filterName => { payload.widget.dashboard.filters.$$items.find(f => f.jaql.title === filterName).jaql.filter = {} payload.widget.dashboard.filters.$$items.find(f => f.jaql.title === filterName).jaql.filter.contains = searchInput }) payload.widget.dashboard.filters.update(payload.widget.dashboard.filters.$$items, { refresh: true, save: true }) } 3. Press "Next" button and paste the following code: { "type": "filterByDimensions", "title": "title", "data": { "filters": "filters" } }​ 4. Click on "Create".  5. Navigate to the "Editor" tab and update "filters" array to include your dashboard filter titles. In our case, it's "Brand" and "Category".   Now test it out and feel free to use it as an example to develop your own solutions, and drop a comment with your experience!   😊 Disclaimer : Please note that this blog post contains one possible custom workaround solution for users with similar use cases. We cannot guarantee that the custom code solution described in this post will work in every scenario or with every Sisense software version. As such, we strongly advise users to test solutions in their environment prior to deploying them to ensure that the solutions proffered function as desired in their environment. For the avoidance of doubt, the content of this blog post is provided to you "as-is" and without warranty of any kind, express, implied, or otherwise, including without limitation any warranty of security and or fitness for a particular purpose. The workaround solution described in this post incorporates custom coding, which is outside the Sisense product development environment and is, therefore, not covered by Sisense warranty and support services.

      Liliia Kislitsyna
      Liliia KislitsynaPosted 2 years ago • Last reply 1 year ago
      3
               
      • BloxChevronRightIcon

      BloX Sort Values Action

               

      Challenge: When making a BloX widget, there is no UI element that you can use to sort the widget. Solution: This custom BloX action allows you to trigger a sort for any widgets including itself. Steps To Implement: STEP 1A: CREATING THE ACTION Create the action using the following code and name it  "sort value" var widgetIds = payload.data.widgetToSort var valueIndexToSort = payload.data.valueIndexToSort var sortOrder = payload.data.sortDirection payload.widget.dashboard.widgets.$$widgets .filter(i => widgetIds.includes(i.oid)) .forEach(function (widget) { // delete all sorts widget.metadata.panels[1].items.forEach(function (i) { if (i.jaql.hasOwnProperty('sort')) { delete i.jaql.sort } }) // add new sort widget.metadata.panels[1].items[valueIndexToSort].jaql.sort = sortOrder widget.changesMade() widget.refresh() }) STEP 1B: CREATING THE ACTION SNIPPET In Step 2 of 2 Create Action Snippet add the following snippet: { "type": "sort value", "title": "sort", "data": { "widgetToSort": "enter ID here", "sortDirection": "choose desc/asc", "valueIndexToSort": "0" } } STEP 2: IMPLEMENTING THE ACTION In the example gif, the clickable element was used. You can find it in the action snippets. You can also use this action in a standard action button. Here is the code for the clickable element: { "type": "TextBlock", "text": "⬆", "selectAction": { "type": "sort value", "title": "sort", "data": { "widgetToSort": "5f1a0895fb660605bcc72a3a", "sortDirection": "asc", "valueIndexToSort": "0" } } }

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      6