The Zurich release has arrived! Interested in new features and functionalities? Click here for more

Alex Coope - SN
ServiceNow Employee
ServiceNow Employee

This past week, I had an interesting question from one of our SC's. He's working with one of our Customers and they would like to be able to translate some of their Dashboards into other Languages. Immediately I started thinking - what type of dashboard, what languages, how could we identify all the different strings. Before I knew it, I had started to map out some of the table hierarchies involved. And a few short hours later you guessed it, we had another prototype Localization Framework artifact.

 

If you haven't read my other LF Artifact blog posts, check them out here before continuing as this post will go 
into some fairly technical concepts:

Easy - Extending the Localization Framework to Portal Announcements
Easy - Need to translate a Guided Tour? - check this

Medium - Translating Surveys and Assessments
Complex - Need to translate a portal - check this
Complex - Need to translate a configurable workspace? - check this

Note - They progress in levels of difficulty, this one is between Medium and Complex.

 

 

Dashboards come in different styles.

When it comes to dashboards, the top of the data pyramid we're going to focus on here is the entries in the [pa_dashboards] table. From here, there are basically two types of Dashboard. Those with a portal page and those without.

 

It is important to note, that there are many various Child and grand-Child records that we need to follow, which can sometimes go as far as cubes, widgets, breakdowns and their respective sources.

 

For the sake of this prototype, we tested against two dashboard examples from the demo data:

  • "IT Manager"
  • "Service Catalog Overview"

This is because these two dashboards exemplify the idea of these two different type of dashboards we are trying to focus on.

There are others, but for now we're just focussing on these.

 

After mapping out the relationships and understanding how a dashboard knows what report to show and where, we have the skeleton of our query.

 

 

The Configuration.

Skipping ahead a little bit (to save time), we are going to define our "Request Translation" UIAction on that [pa_dashboards] table, and it will look like this:

AlexCoopeSN_0-1679393508501.png

 

I've highlighted the "internal name" that we're going to give to our Artifact (proto_pa_dashboards) - this will be important next:

Our Artifact will be called like this:

AlexCoopeSN_1-1679393651668.png

 

 And our Processor script will be this:

var LF_PAdashboards = Class.create();
LF_PAdashboards.prototype = Object.extendsObject(global.LFArtifactProcessorSNC, {
    category: 'localization_framework', // DO NOT REMOVE THIS LINE!

    /**********
     * Extracts the translatable content for the artifact record
     * 
     *  params.tableName The table name of the artifact record
     *  params.sysId The sys_id of the artifact record 
     *  params.language Language into which the artifact has to be translated (Target language)
     * @return LFDocumentContent object
     **********/
    getTranslatableContent: function(params) {
        /**********
         * Use LFDocumentContentBuilder to build the LFDocumentContent object
         * Use the build() to return the LFDocumentContent object
         **********/

        // we need to kow what is related to the dashboard we're requesting to be translated
        var tableName = params.tableName;
        var sysId = params.sysId;
        var language = params.language;
        var lfDocumentContentBuilder = new global.LFDocumentContentBuilder("v1", language, sysId, tableName);
        var pageArr = [];

        try {
            // let's get this dashboard's record
            var getDash = new GlideRecord('pa_dashboards');
            getDash.addQuery('sys_id', sysId);
            getDash.query();
            if (getDash.next()) {
                lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getDash, "Dashboard", getDash.getDisplayValue());
                // we have the dashboard, no we need to check tabs and breakdown sources
                var tabMap = new GlideRecord('pa_m2m_dashboard_tabs');
                tabMap.addEncodedQuery('dashboard.sys_id = ' + getDash.sys_id);
                tabMap.orderBy('order');
                tabMap.query();
                while (tabMap.next()) {
                    var getTabs = new GlideRecord('pa_tabs');
                    getTabs.addQuery('sys_id', tabMap.tab.sys_id);
                    getTabs.query();
                    if (getTabs.next()) {
                        // now we need to process the "page" of this tab
                        lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getTabs, "Tab", getTabs.getDisplayValue());
                        _getPage(getTabs.page.sys_id);
                    }
                }
                // we need to get the Breakdown sources for this dashboard as well
                var breakMap = new GlideRecord('pa_m2m_dashboard_sources');
                breakMap.addEncodedQuery('dashboard.sys_id=' + getDash.sys_id);
                breakMap.orderBy('order');
                breakMap.query();
                while (breakMap.next()) {
                    var getBreakdownSource = new GlideRecord('pa_dimensions');
                    getBreakdownSource.addQuery('sys_id', breakMap.breakdown_source.sys_id);
                    getBreakdownSource.query();
                    if (getBreakdownSource.next()) {
                        lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getBreakdownSource, "Breakdown", getBreakdownSource.getDisplayValue());
                        // now we need to go down into each breakdown
                        var getBreakdown = new GlideRecord('pa_breakdowns');
                        getBreakdown.addEncodedQuery('dimension.sys_id=' + getBreakdownSource.sys_id);
                        getBreakdown.query();
                        while (getBreakdown.next()) {
                            lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getBreakdown, "Breakdowns", getBreakdown.getDisplayValue());

                            // Breakdown Mappings
                            var paBreakMap = new GlideRecord('pa_breakdown_mappings');
                            paBreakMap.addEncodedQuery('breakdown.sys_id=' + getBreakdown.sys_id);
                            paBreakMap.query();
                            while (paBreakMap.next()) {
                                lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(paBreakMap, "Breakdown Mapping", paBreakMap.getDisplayValue());
                            }

                            // Breakdown Relations
                            var paBreakRel = new GlideRecord('pa_breakdown_relations');
                            paBreakRel.addEncodedQuery('breakdown.sys_id=' + getBreakdown.sys_id);
                            paBreakRel.query();
                            while (paBreakRel.next()) {
                                lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(paBreakRel, "Breakdown Relations", paBreakRel.getDisplayValue());
                            }

                            // Indicators
                            var getIndicatorBreakdown = new GlideRecord('pa_indicator_breakdowns');
                            getIndicatorBreakdown.addEncodedQuery("breakdown.sys_id=" + getBreakdown.sys_id);
                            getIndicatorBreakdown.query();
                            while (getIndicatorBreakdown.next()) {
                                // we shouldn't need to follow the Breakdown Source as it should already be covered by a previous query

								// we need to check for the indicators and the cubes
                                var getIndicator = new GlideRecord('pa_indicators');
                                getIndicator.addQuery('sys_id', getIndicatorBreakdown.indicator.sys_id);
                                getIndicator.query();
                                if (getIndicator.next()) {
                                    lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getIndicator, "Indicator Breakdown", getIndicator.name+' - '+getIndicator.getDisplayValue());
                                    // lets check the "Indicator Source"
                                    var getIndSource = new GlideRecord('pa_cubes');
                                    getIndSource.addQuery('sys_id', getIndicator.cube.sys_id);
                                    getIndSource.query();
                                    if (getIndSource.next()) {
                                        lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getIndSource, "Indicator Source", getIndSource.name+' - '+getIndSource.getDisplayValue());
                                    }
                                }
                            }
                        }
                    }
                }
            }
            // we need to get some standard strings
            lfDocumentContentBuilder.processString('No data to display', "Standard Messages", "Message");
            lfDocumentContentBuilder.processString('Name', "Standard Messages", "Message");
            lfDocumentContentBuilder.processString('Score', "Standard Messages", "Message");
            lfDocumentContentBuilder.processString('Trend', "Standard Messages", "Message");
            lfDocumentContentBuilder.processString('Breakdowns', "Standard Messages", "Message");
            lfDocumentContentBuilder.processString('Records', "Standard Messages", "Message");
            lfDocumentContentBuilder.processString('Scorecard', "Standard Messages", "Message");
            lfDocumentContentBuilder.processString('Loading...', "Standard Messages", "Message");
        } catch (err) {
            gs.log('Error in getDash - ' + err.name + ' - ' + err.message);
        }


        function _getPage(pageSYS) {
            try {
                var portalPage = new GlideRecord('sys_portal_page');
                portalPage.addQuery('sys_id', pageSYS);
                portalPage.query();
                if (portalPage.next()) {
                    lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(portalPage, "Portal Page", portalPage.getDisplayValue());
                    // now we need to go through the dropdowns
                    var getPortalDropzones = new GlideRecord('sys_portal');
                    getPortalDropzones.addEncodedQuery('page.sys_id=' + portalPage.sys_id);
                    getPortalDropzones.query();
                    while (getPortalDropzones.next()) {
                        lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getPortalDropzones, "Portal Dropzones", getPortalDropzones.getDisplayValue());
                        // now we need to get the title of the report on the dashboard
                        var getTitle = new GlideRecord('sys_portal_preferences');
                        getTitle.addEncodedQuery('portal_section.sys_id=' + getPortalDropzones.sys_id);
                        getTitle.addQuery('name', 'sys_id');
                        getTitle.query();
                        while (getTitle.next()) {
                            // we need to get the exact report title
                            var getRep = new GlideRecord('sys_report');
                            getRep.addQuery('sys_id', getTitle.value.toString());
                            getRep.query();
                            // we also need to check pa_widgets if we don't have a report
                            if (!getRep.hasNext()) {
                                var getPaWidget = new GlideRecord('pa_widgets');
                                getPaWidget.addQuery('sys_id', getTitle.value.toString());
                                getPaWidget.query();
                                if (getPaWidget.next()) {
                                    if (getPaWidget.type != "process") {
                                        lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getPaWidget, "PA Widget", getPaWidget.getDisplayValue());
                                        // we need to now go into the "Indicator"
                                        var getWidInd = new GlideRecord('pa_indicators');
                                        getWidInd.addQuery('sys_id', getPaWidget.indicator.sys_id);
                                        getWidInd.query();
                                        while (getWidInd.query()) {
                                            lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getWidInd, "PA Widget Indicator", getWidInd.getDisplayValue());
                                        }
                                    } else {
										// this is for Workbench / Process types
										var getMainWidInd = new GlideRecord('pa_widget_indicators');
										getMainWidInd.addQuery('widget', getPaWidget.sys_id);
										getMainWidInd.query();
										while(getMainWidInd.next()){
											lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getMainWidInd, "Main Widget Indicator", getMainWidInd.getDisplayValue());
											// now we also need to get the Element of these
											// these are choice entries - will need to create a Processor Script for these

											// we need to get the Supporting Widget Indicators
											var getSupWidInd = new GlideRecord('pa_widget_indicators');
											getSupWidInd.addQuery('widget_indicator', getMainWidInd.sys_id);
											getSupWidInd.query();
											while(getSupWidInd.next()){
												lfDocumentContentBuilder.processTranslatableFieldsForSingleRecord(getSupWidInd, "Supporting Widget Indicators", getSupWidInd.getDisplayValue());
											}
										}
									}
                                }
                            }
                            // if we do have a report
                            if (getRep.next()) {
                                lfDocumentContentBuilder.processString(getRep.title.toString(), "Report Title", getRep.getDisplayValue());
                            }
                        }
                    }
                }
            } catch (err) {
                gs.log('Error in _getPage - ' + err.name + ' - ' + err.message);
            }
        }

        return lfDocumentContentBuilder.build();
    },

    /**********
     * Uncomment the saveTranslatedContent function to override the default behavior of saving translations
     * 
     *  documentContent LFDocumentContent object
     * @return
     **********/
    /**********
        saveTranslatedContent: function(documentContent) {},
    **********/

    type: 'LF_PAdashboards'
});

 

Please remember that this is a prototype, and as such it almost certainly won't pick up everything on a Dashboard. For example, it won't pick up field labels and choices used in graphs, however you can use it to get you the majority of the way there.

 

It's also fair to say that the code could be significantly optimised with more time. Essentially the idea with the artifact is to start from the very top table where all the relationships from a dashboard begin, then follow each and every "Related List" until no more are found.

 

 

Did it work?

So, let's look at the "IT Manager" dashboard. Here it is in English:

AlexCoopeSN_2-1679393906631.png

 

Here is an LF task of it being translated into Japanese:

AlexCoopeSN_3-1679394027295.png

 

And here is the Dashboard translated into Japanese:

AlexCoopeSN_4-1679394225612.png

 

 

What have we learned?

So we can see that it is  indeed possible to translate the majority of a dashboard with an artifact, and it got us the majority of the way there.

 

Now you have the choice of whether you want to translate all of your dashboards, or just the ones shared across user groups - that might be quite a big decision for some of you out there.

 

Let me know in the comments if you have any ideas for tweaks / improvements based on your testing as it will benefit the community. And feel free to DM / email me any suggestions for more artifacts my team can potentially prototype.

 

 

 

And if there's enough asks, we might bundle our prototypes into an update-set on the store. We've even been working on one for the mobile apps 😉

And as usual, please like, share and subscribe as it always helps

 

 

Comments
Travis Rogers
ServiceNow Employee
ServiceNow Employee

Great article, Alex! I see in your example you used a UI16 dashboard. As customers are being nudged to migrate those over to highly configurable Seismic/Next Experience Dashboards, I’m curious:  Does this work for Next Experience dashboards as well?

Alex Coope - SN
ServiceNow Employee
ServiceNow Employee

@Travis Rogers,

That's a good question, because Siesmic/Next Experiences are a bit different in their structure. So, you could leverage our prototype workspace Artifact to get the majority of the UI in say "Platform Analytics Workspace" translated, but it probably won't pickup all of the reports and dashboards in it,

I'll add it to our list 👍, but feel free to test out the Workspace artifact to see how much it identifies in your use-case,

Many thanks,

kind regards

jhondoesheekh
Giga Explorer

Thanksfor sharing it ca it translate into UAE language.

timjeoung
Tera Contributor

Hi Alex,

Thank you so much for the article. I have a question: how can we get the 'Edit Translation' button on the dashboard page? Currently, I can only see the 'Request Translation' button, which is used for localization. However, I prefer the 'Edit Translations' option for making minor changes and easily checking for missing translations.

Alex Coope - SN
ServiceNow Employee
ServiceNow Employee

@timjeoung,

I'm afraid currently the "edit translations" feature is only available for the Catalog Items artifact ootb as it requires some very specific set up. I will however feedback it back to raise in our next strategy discussions,

Many thanks,
kind regards 

Version history
Last update:
‎03-21-2023 11:13 AM
Updated by:
Contributors