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
    Developers: Embedding
    • 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
               
      • Embedding AnalyticsChevronRightIcon

      Embedding Sisense Dashboards In Salesforce

                       

      Embedding Sisense content into the applications where end users spend most of their time is one of the best ways to leverage your analytics investment. In this post, we'll go over embedding a Sisense dashboard in Salesforce, using Salesforce objects to filter the dashboard to the right context.   1. Create a new VisualForce page From the Setup menu, select the Developer Console option The Developer Console will open, select File -> New -> Visualforce Page 2. Add an iFrame to embed the Sisense dashboard Add a  standardController  to specify the desired page where the Sisense dashboard will be added. In this example, we'll use the Account record page: <apex:page standardController="Account"> <apex:iframe src="https://<Sisense Server URL>/app/main#/dashboards/<dashboardID>?embed=true&h=f&t=f&r=f&l=f" width="100%" height="1024px" scrolling="true" id="sisenseIframe"/> </apex:page> Use the embed=true parameter to remove the headers, and also pass which page components you want displayed (toolbars, filters, navigation, etcetera).  Documentation. 3. Make it dynamic Salesforce allows you to pass object values down to the embedded content. In this case, we will pass the Account ID to filter the dashboard to the account record. NOTE: The field you are passing as filter needs to exist in your Sisense data model. First, filter the dashboard by the column that contains the Salesforce object values, and get the encoded filter JAQL using the following post:  passing dashboard filters by URL parameters . Find the part of the encoded JAQL that contains the dimensional value (in this example the Account ID), and replace it with the Salesforce placeholder: {!Account.Id}: filter=%5B%7B%22jaql%22%3A%7B%22table%22%3A%22Customers%22%2C%22column%22%3A%22SFDCAccountID%22%2C%22dim%22%3A%22%5BCustomers.SFDCAccountID%5D%22%2C%22datatype%22%3A%22text%22%2C%22merged%22%3Atrue%2C%22title%22%3A%22SFDCAccountID%22%2C%22filter%22%3A%7B%22explicit%22%3Atrue%2C%22multiSelection%22%3Atrue%2C%22members%22%3A%5B%22{!Account.Id}%22%5D%7D%2C%22collapsed%22%3Afalse%2C%22datasource%22%3A%7B%22title%22%3A%22MW%22%2C%22fullname%22%3A%22LocalHost%2FMW%22%2C%22id%22%3A%22aLOCALHOST_aMW%22%2C%22address%22%3A%22LocalHost%22%2C%22database%22%3A%22aMW%22%2C%22lastBuildTime%22%3A%222020-09-30T14%3A53%3A50.547Z%22%7D%7D%2C%22%24%24events%22%3A%7B%7D%2C%22instanceid%22%3A%22DC3F2-74F4-1C%22%2C%22%24filter%22%3A%7B%7D%2C%22%24permissionsService%22%3A%7B%22sourceToGlobalObjects%22%3A%7B%22dashboard%22%3A%22prism.activeDashboard.userAuth%22%2C%22widget%22%3A%22prism.activeWidget.dashboard.userAuth%22%7D%7D%2C%22isCascading%22%3Afalse%2C%22%24%24guid%22%3A%22B7684-3347-E875-9BBB%22%2C%22%24%24hashKey%22%3A%22object%3A2661%22%7D%5D Append it to the URL in the Visualforce page by adding &filter=<encoded jaql> Save the Visualforce page   4. Make the Visualforce Page Available In Salesforce, under Setup, search for Visualforce pages and edit the newly created page Check the "Available for Lighting Experience, Lighting Communities, and the mobile app" check box. Save the page   5. Add the Visualforce Page In Salesforce, under Setup, search for the page where the Sisense dashboard will be displayed. In this example, it's the Account Record Page. Click the Edit button The page layout will be opened. Choose Visualforce from the left side bar and drag it to the desired location in the layout. Select the new component, the settings will open in the right side. Select the newly created Visualforce page, add a label, and enter the desired height (in pixels) for the component.  Save the page layout. Now the page will contain the Sisense dashboard, filtered down by the Salesforce object: Other Considerations: SSO must be set up for a seamless experience, otherwise users may get the Sisense login page when they don't have an active session in Sisense. Visualforce lets you use CSS and Javascript to customize height, width and other styling aspects of the iFrame. Make sure to line up the height of the iFrame with the height of the Lighting component containing the Visualforce page in the layout, so that users don't get multiple scrollbars.

      intapiuser
      intapiuserPosted 3 years ago • Last reply 1 year ago
      5
               
    • Blog banner
      • Best PracticesChevronRightIcon

      Leveraging Sisense Notebooks with Compose SDK

                                                                       

      Leveraging Sisense Notebooks with Compose SDK In today’s developer ecosystem, delivering a tailored creator experience is essential for success, especially when integrating analytics into customer-facing applications. Many existing solutions offer a generic, one-size-fits-all approach, typically centered around drag-and-drop interfaces. While these tools may be convenient, they often limit the depth of customization needed for unique business use cases. Additionally, platforms that offer more advanced capabilities usually require adopting proprietary syntax, which can drive up costs by necessitating specialized expertise. Sisense, however, provides a powerful code-first analytics solution that eliminates these barriers. It allows your data teams to leverage their existing skills—such as SQL, Python, and R—to create highly customized insights that align with your organization's specific needs, without needing proprietary languages or systems. In this article, we’ll explore how you can use SQL specifically to create custom datasets and charts, and then embed those visualizations into your web applications. This workflow emphasizes the creator experience for data teams, empowering them to build meaningful, business-specific analytics that enhances your product’s value. Building Custom Datasets with SQL Let’s dive into a specific use case. Assume you have a commerce table with two key columns: `transaction_amount` and `transaction_status`. You need to analyze revenue, categorizing it by transaction type (e.g., paid, refunded, or canceled). This provides insight into gross revenue versus potential revenue lost to refunds or cancellations. Below is the SQL query used to create a custom dataset for this scenario: ( Dataset Git Repo ) ( Data Model Repo  )     SELECT TO_CHAR(DATE_TRUNC('month', transaction_date), 'YYYY-MM') AS "Date", transaction_id, SUM(CASE WHEN transaction_status = 'Paid' THEN transaction_amount ELSE 0 END) AS "Actual Revenue", SUM(CASE WHEN transaction_status IN ('Refunded', 'Cancelled') THEN transaction_amount ELSE 0 END) AS "Potential Revenue" FROM commerce GROUP BY DATE_TRUNC('month', transaction_date), transaction_id;     This query creates a monthly summary of actual revenue and potential revenue lost due to refunds or cancellations. After running the query, you can preview the output in Sisense and immediately take action. Integrating Custom Data into Fusion Once your custom table is generated, you have several options for incorporating it into your analytics workflow: Adding Notebooks Charts to Dashboards   1. Integrate within the Semantic Model: The SQL table can be added to Sisense’s semantic layer, allowing you to build charts and dashboards on top of it using Fusion Dashboards or Compose SDK. 2. Build Charts for Integration : You can also directly build charts based on this table and pass them to your frontend team for integration into customer-facing applications. Streamlining Developer Workflows with Compose SDK Once your data team has completed their SQL-based analysis and crafted custom datasets and charts, the true value lies in how effortlessly developers can integrate these insights into customer-facing applications. By publishing the dataset to the Fusion semantic model , developers can use Sisense’s command-line tools (like '@sisense/sdk-cli') to dynamically generate TypeScript representations of the dataset right within their IDE. This streamlined process ensures that the work done by analysts is directly accessible to developers, enhancing the builder experience by providing immediate access to the datasets. This integration allows developers to efficiently embed highly customized, meaningful analytics into the front end, ensuring that the insights are not only visually compelling but also tailored to the specific needs of the end users. Creating Custom Visualizations Once charts have been built in Sisense Notebooks and published to Fusion , developers can begin integrating them seamlessly into front-end web applications using the Compose SDK . This process allows developers to embed dynamic, SQL-powered visualizations directly into customer-facing apps. By leveraging the Compose SDK, developers can access the published charts, configure them for specific frontend needs, and ensure a consistent, interactive user experience. This workflow streamlines the integration of custom analytics into web apps, providing flexibility and efficiency for development teams of all varieties. This code snippet demonstrates how to integrate both a custom column chart and an existing dashboard widget into a React application using Sisense’s Compose SDK. The `ColumnChart` component displays actual and potential revenue by year, while the `DashboardWidget` component integrates a pre-existing Sisense dashboard into the application. DashboardWidget   Sisense CLI       import { ColumnChart, DashboardById, DashboardWidget } from '@sisense/sdk-ui'; import * as DM from "../notebooks-tutorial" import { measureFactory } from '@sisense/sdk-data'; function ComposeSDK() { return ( <> <ColumnChart dataSet={DM.DataSource} dataOptions={{ category: [DM.ActualVsPotential.transaction_date.Years], value: [ measureFactory.sum(DM.ActualVsPotential.actual_revenue, 'Actual Revenue'), measureFactory.sum(DM.ActualVsPotential.potential_revenue, 'Potential Revenue'), ], breakBy: [DM.commerce.age_range], }} styleOptions={{ height: 500, }} /> <DashboardWidget dashboardOid="66e8aa5e724a0b00338cbed9" widgetOid="66e8c716724a0b00338cbefc" /> </> ); } export default ComposeSDK;   Conclusion:  This code-first approach to analytics meets technical creators where they are, empowering data teams and developers alike. Whether your analysts are leveraging their existing skills in SQL through the powerful Notebooks environment or your developers are dynamically generating insights within their IDE using the Compose SDK , Sisense offers a seamless and collaborative experience. By enabling teams to work with familiar tools and environments, Sisense allows you to deliver powerful, tailored analytics that fit your organization’s unique needs. This fluid, creator-friendly workflow ensures that both data teams and developers can focus on building impactful insights without the friction of proprietary systems, enhancing the overall efficiency and creativity of your analytics solutions.

      Sisense User
      Sisense UserPosted 1 year ago
      0
               
      • Embedding AnalyticsChevronRightIcon

      Dynamic Resize For Embedded IFrames

                                       

      If you've ever attempted to dynamically resize your embedded iFrames in your parent application, you may have experienced a  CORS  conflict. Basically, since your parent application and sisense application serve from different domains, your browser restricts HTTP responses for security reasons. Luckily, we have a workaround that leverages the Window.postMessage() function. This solution dynamically resizes your embedded iFrame based on the height of the contents. // Within your SISENSE APPLICATION, apply this script on the dashboard to be embedded via iFrame: dashboard.on('refreshend', function () { var heightValue = 0, columnArr = dashboard.layout.columns, columnHeight = [], targetWindow = window.parent, // replace with your target domain targetOrigin = 'http://main.mytestapp.com:8083'; // iterate through columns for (var i = 0; i < columnArr.length; i++) { var cellsLength = columnArr[i].cells.length, heightTotal = 0; // increment through each column widget for (var j = 0; j < cellsLength; j++) { var height = columnArr[i].cells[j].subcells[0].elements[0].height; heightTotal += height; } columnHeight.push(heightTotal); // assign max aggregate column height heightValue = columnHeight.reduce(function (a, b) { return Math.max(a, b); }); } targetWindow.postMessage({ heightValue: heightValue }, targetOrigin); }); // Within your PARENT APPLICATION: window.addEventListener('message', receiveMessage, false); function receiveMessage(event) { // replace with your sisense application origin var sendingOrigin = 'http://reporting.mytestapp.com:8080'; if (event.origin !== sendingOrigin) { throw new Error('bad origin:', event.origin); } else { try { // replace with your iFrame ID document.getElementById('ifm').height = event.data.heightValue; } catch (err) { throw new Error(err.name + ':', err.message); } } }

      intapiuser
      intapiuserPosted 3 years ago • Last reply 2 years ago
      1
               
      • Embedding AnalyticsChevronRightIcon

      IFrame Embedding And Safari

               

      For organizations embedding Sisense into their application using an iFrame we have identified a situation where the iFrame may not render when browsing to the application using Safari. This can be fixed with one setting change in the Safari browser. To do this, follow the steps below: Open Safari Pull down the Safari menu and choose "Settings..."    3. In the resulting Preferences window, click the "Privacy" tab and uncheck the "Prevent cross-site tracking" box   4. Close the Preferences window 5. Reload the application page Please note that this is specific to iFrame embedding and should not be an issue when browsing directly to the Sisense site.

      intapiuser
      intapiuserPosted 3 years ago • Last reply 2 years ago
      2
               
      • Embedding AnalyticsChevronRightIcon

      SisenseJS With ReactJS

                       

      Gitlab Link:  https://gitlab.com/SisenseJS/react-sample   This project was generated with  create-react-app  version 1.5.2.  The goal is to show a basic integration of Sisense.js using ReactJS. When you load the application, it makes some API calls to fetch all Sisense dashboards whose title are prefixed with  Sample  and adds option to the dropdown list.  Upon selection of a dashboard (uses the first result as default), the application loads the widgets from that dashboard onto the page.  For simplicity, certain widget types are excluded from display (textbox and indicator widgets)   Dashboard The application gets most of the widgets for a given dashboard and displays them on the page.  The end user can click to download each widget, as either an image or CSV   Prerequisite Please note you need to be integrated with SSO or WAT in order to be able to implement Sisense.js embedding.  If you have not done so already, please refer to documentation regarding how to onboard SSO or WAT, and how to integrate that with embedding.   Configuration In order to setup this application for your Sisense server, check out the configuration file at  /src/config/sisense.js .  This file contains some settings, such as the dashboard title prefix, your Sisense server's address, etc that you may want to update before running.   Development server Run  npm start  to build and run the application, then navigate to  http://localhost:3000/ . The app will automatically reload if you change any of the source files.   Production server Run  npm run build  to generate a production build out of the application.  Once built, you can deploy the web app to a web server, as per  Instructions from React   Further help To get more help on  create-react-app  for ReactJS check out their  Github Repository . For more help with Sisense.js, check out the Sisense documentation  here .

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Embedding AnalyticsChevronRightIcon

      SisenseJs Custom Filter Samples

               

      Gitlab Link:  https://gitlab.com/SisenseJS/filter-samples This project was created to show several ways to integrate 3rd party components with Sisense.js.  Many web apps that leverage Sisense.js for embedding do so because they already have a consistent look/feel for their application and want the controls related to Sisense to match.  This sample project showcases a few common filter types, in a simple web page.  This webpage uses  Semantic UI  framework, but this approach would be similar for Bootstrap, Material Design, etc  Workflow Almost all custom filter controls follow the same workflow: Fetch data  - Make an API call to fetch data from Sisense, and add the results as potential selections in the UI Initialization  - Setup the component with this data User Selection  - When a user makes a selection on this control, set a filter on your Sisense dashboard using our JavaScript API Filter Samples TOGGLE A simple toggle switch, that sets/unsets a filter when clicked DROPDOWN A drop-down menu built using Semantic UI's  dropdown  framework. This example has a simple dropdown that fetches a list of unique values from Sisense, and sets a filter based on user selection (supports multi-select). Also included is a more complex dropdown that nests multiple dimensions into levels.  This takes a bit of HTML manipulation to create the hierarchy, but generally, it works the same as the simple example. CALENDAR DATE RANGE PICKER A dropdown that displays a date range picker, based on an open-source  JavaScript component . This does require adding  moment.js ,  daterangepicker.js , and  daterangepicker.css  into your HTML page, but the JavaScript implementation of this component is similar to the rest of the examples. One of the nice features of this component is that it allows you to define shortcuts for date ranges (yesterday, last month, etc) RESET BUTTON This is a simple button that resets the filters on the page. Resetting the filters for your Sisense dashboard is pretty simple through the JavaScript API, most of the code here is for resetting the UI for the other filter components. File Structure SEMANTIC This directory contains all files required for Semantic UI. No customizations were made here CONFIG.JS This file contains the base object that will store a reference to all settings/methods related to this website. When setting this up, you should update the server address and potentially the Elasticube. INDEX.HTML This HTML page is the default landing page for this website. It loads  config.js  and  main.js , to initialize the application, and includes the definition of the filter components and widgets to display. This sample uses widgets from the  Sample Healthcare  dashboard that comes with the free trial of Sisense, so if you change the Elasticube in  config.js  you should also change the widget IDs and filter properties defined in this file. MAIN.CSS This contains any styling outside of the Semantic UI framework MAIN.JS This is the main JavaScript file that initializes the web page. This file is split into two sections: Initialization & Runtime INITIALIZATION initScript  - Adds a <script /> tag to your HTML page, to add the Sisense.js Library connect  - Once the Sisense.js library has finished loading, connect to your Sisense server loadApplication  - Once connected, create a new Dashboard and find all the widgets & filters on the web page. For each widget found, run the  loadWidget  function. For each filter found, run the  initFilter  function. loadWidget  - For a given widget ID, load the widget and render to the page initFilter  - For a given filter, initialize it and add an event handler for when a user interacts with it RUNTIME setFilter  - Runs after a filter selection was made, sets the filter using Sisense JavaScript API initScript () - Kicks off the initialization workflow Further help To get more help on Semantic UI their  Documentation . For more help with Sisense.js, check out the Sisense documentation  here .

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Embedding AnalyticsChevronRightIcon

      Change Filters When Using SisenseJS

               

      Introduction Sisense allows importing widgets and filters as components to be presented in different websites. This is done by  SisenseJS . Sometimes, it is required to modify the widgets filters based on website attributes such as logged in user, location, previous user selections etc. Using the below scripts, you will be able to modify the dashboard filters and save them to the server so data will be saved even when refreshing the page. Code Snippet: 1. Modifying dashboard filters: // Find the filter we want to change based on the dim var filterToChange = dash.filters.$$filters.find(item => item.jaql.dim == "[Commerce.Age Range]"); if (filterToChange && filterToChange.$$model.jaql.filter){ var members = filterToChange.$$model.jaql.filter.members; // Check if we already have the member selected if (members && !members.includes("65+")) { // Push a new member to the filter members.push("65+"); // Save the dashboard dash.$$model.$dashboard.updateDashboard(dash.$$model,"filters"); } } 2. Save filters changes to the server is done by sending "true" as the second parameter to the "connect" function: Sisense.connect('http://10.20.4.155:8081',true).then(function(app) { . . . }); 3. Create and add new filter to the dashboard: var applyFilter = function (dim, value, activeDashboard) { // Get the active dashboard, only run if sisense is loaded if (activeDashboard) { // Create the filter options var filterOptions = { save: true, refresh: true, unionIfSameDimensionAndSameType: false }; // Create the jaql for the filter var jaql = { 'datatype': 'text', 'dim': dim, 'filter': { 'multiSelection': true, 'members': value, 'explicit': true }, "title": dim }; // Create the filter jaql var applyJaql = { jaql: jaql }; // Set the new filter activeDashboard.$$model.filters.update(applyJaql, filterOptions); } }; Download: ModifyFiltersSisenseJS.html

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Embedding AnalyticsChevronRightIcon

      Sisense Storytelling: Embedding Sisense Interactive Dashboards in PowerPoint 2013 / 2016

               

      Introduction: This post describes how live interactive dashboards can be embedded into Microsoft PowerPoint presentations. A common use for this ability is to leverage the Sisense dashboards for storytelling, allowing the presenter of the dashboard to answer questions on-the-fly be filtering and drilling into the data during the meeting itself. Big Plus: Whenever the presentation is opened and presented, the data and dashboards will be updated with the most up to date data in the ElastiCube = No need to manually update your presentations anymore! In order to embed a Sisense dashboard into a Microsoft PowerPoint presentation, the following steps are required. A short video example of this can be seen in the following video: Prerequisites: 1. Sisense installation and dashboards 2. Sisense server enabled with SSL -  Setting-Up-SSL 3. PowerPoint 2013 / 2016 or Later Installation: Install the " Web Viewer " add-on on the PC running the PowerPoint presentation (this can be found at the MSFT Office Store). Note: The "Web Viewer" add-on is a 3rd party SW and is not developed or supported by Sisense. After completing the installation, in any slide in which you wish to embed a dashboard or widget, press on Insert --> My Add-ins --> Web Viewer : In the URL box, enter the URL of the dashboard or widget you would like to embed. Note: Make sure to remove the https://  at the beginning of the URL and add the embed=true switch to the end. (more on iframe embedding options can be found at:  Embedding Dashboards and Widgets ). For example: Note: The computer running the PowerPoint slideshow must have network access to the Sisense server (in case a firewall is in place, please create the appropriate rules to allow access to the ElastiCube server). Due to a limitation in the "Web Viewer" add on, in order to update the dashboards / widgets, close the PowerPoint presentation and open it again.

      intapiuser
      intapiuserPosted 3 years ago
      0
               
      • Add-ons & Plug-InsChevronRightIcon

      SisenseJS Plug N Play - Embed In Minutes!

                               

      Sisense JS Plug N Play - Embed In Minutes! A simple and easy jump start solution for embedding Sisense using Sisense.js on your website! SisenseJSPlugNPlayV1 DEPENDENCIES: JQuery SisenseJsPlugNPlay.js Sisense deployment with open CORS Cookie from sisense (using SSO, can be tested by logging into sisense webapp on a different tab in the browser) HOW TO USE: Create empty Div elements in your html page and assign ids using ‘loc{Index}’ (‘loc1’, ‘loc2’...) Import SisenseJsPlugNPlay.js Create a configuration object: var config = { settings: { server: '{SISENSESERVERURL}', dashboardidtouse: '{DASHID}', elasticube: { title: "{ECTITLE}", id: "{ECID}", address: "{ECADDRESS}", database: "{ECDB}" } }, app: null, dashboard: null, widgetsIdList: ['WID1', 'WID2', 'WID3', 'WID4', 'WID5'], myWidgetsDict: {}, widgetsInDashDict: {}, } Call: SisenseJSPlugNPlay(config);

      intapiuser
      intapiuserPosted 3 years ago
      0