[Rt-commit] rt branch 5.0/help-basics created. rt-5.0.1-609-ga197f6af9b

BPS Git Server git at git.bestpractical.com
Tue Sep 14 19:50:30 UTC 2021


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "rt".

The branch, 5.0/help-basics has been created
        at  a197f6af9bac22e9fdba89ec766f403d9e8b7e5f (commit)

- Log -----------------------------------------------------------------
commit a197f6af9bac22e9fdba89ec766f403d9e8b7e5f
Author: Steven Burr <steve at bestpractical.com>
Date:   Fri Jun 11 14:56:06 2021 -0400

    Add popup help capabilities
    
    Help icons can be added anywhere on RT pages. When clicked, they
    will display the contents of an associated Article in a dismissable
    popover dialog. The Article content can be retrieved server-side
    at page rendering time or dynamically when clicked via AJAX.
    
    The (user-overridable) $ShowInlineHelp setting governs whether the
    help icons for inline popup help topics are displayed or not.
    
    No help articles are actually added as part of this commit.
    Just the underlying functionality.

diff --git a/devel/docs/help-system.pod b/devel/docs/help-system.pod
new file mode 100644
index 0000000000..995edc79ef
--- /dev/null
+++ b/devel/docs/help-system.pod
@@ -0,0 +1,580 @@
+=head1 OVERVIEW
+
+RT adds help icons to various elements on pages throughout the application.
+When the user clicks the help icon, a popup dialog will display useful information
+related to that element.
+
+The RT Help System can be used in a number of ways:
+
+=over
+
+=item *
+It is used internally by RT for help topics that ship with RT
+
+=item *
+It can be used to modify or augment the help topics that ship with RT
+
+=item *
+It can be used by extension authors to provide help for their extensions in a uniform fashion
+
+=back
+
+=head2 How it works
+
+Help content is managed as a collection of Articles in specially-designated Classes.
+If a Class has the special C<"RT Help System"> custom field set to C<"yes"> then that Articles
+in that Class are eligible to participate in the lookup of help topics. A second custom
+field called C<"Locale"> will identify the language used by Articles in that Class.
+
+When asked to display help for a particular topic, the RT help system will look for a
+Class that has been tagged as C<"RT Help System"> and has a C<"Locale"> compatible with the
+current user's language setting.
+
+Assuming it finds such a Class, RT will look inside it for an Article with a Name matching
+the help topic.
+
+=head2 Sync vs Async
+
+There are basically two modes of operation for the RT help system: synchronous and
+asynchronous.
+
+In synchronous mode, all of the help content is either retrieved or supplied directly on
+the server side when the initial page is rendered. This means that the help content itself
+is delivered to the browser.
+
+In asynchronous mode, only the help topic is supplied when the page is rendered. Only when
+the user clicks on the help icon is the help content dynamically retrieved from the server
+and displayed in the popup dialog. See L</Async> for more details.
+
+Both modes can be used interchangeably on the same page.
+
+=head1 USAGE
+
+The RT help system can be used at render time on the server. For example,
+in a Mason template, you might use the `PopupHelp` template to annotate a
+form field
+
+    <label for="ticketId">Ticket Id</label>
+    <& /Elements/PopupHelp, key => 'My Topic' &>
+    <input type="text" name="ticketId" />
+
+The RT help system can also be used at runtime on the client. For example,
+you can also add the same help topic to every HTML element matching a certain
+query. The following would associate a help topic to a specific button
+
+    <script>
+    jQuery(document).ready(function() {
+        addPopupHelpItems( { "selector": "button#save-form", "title": "My Topic" } )
+    })
+    </script>
+
+=head1 REFERENCE
+
+There are three primary ways to use the RT Help System: 
+
+=over
+
+=item *
+L</Mason Templates>
+
+=item *
+L</HTML Attributes>
+
+=item *
+L</JavaScript>
+
+=back
+
+Of course, you are also free to use the lower-level contsructs as well.
+
+=over
+
+=item *
+L</Programmatic API>
+
+
+=back
+
+=head2 Mason Templates
+
+Add a C</Elements/PopupHelp> component anywhere in a Mason template
+
+    <& /Elements/PopupHelp, key => "My Topic" &>
+
+This will render an empty HTML span element
+
+    <span data-help="My Topic"
+          data-content="The article contents"
+          data-action="replace"
+          style="display: none;"
+    />
+
+which will be picked up and processed on page load by the default "helpification"
+rule when all of the accumulated rules are executed when C<renderPopupHelpItems> is
+called (for example, in the C<Elements/Footer> component in the page footer).
+
+If no valid help article named C<My Help Topic> is found, (see L</OVERVIEW>) or the
+C<ShowInlineHelp> setting/user-preference is falsy the C<<span>> will be suppressed
+altogether.
+
+Because the help content has already been retrieved and sent to the client,
+it will already be in the DOM and there should be virtually no delay when displaying
+the help popup following a user click.
+
+=head3 Example
+
+To add help to a form field, a Mason template might create a help tag directly,
+
+    <label for="ticketId">Ticket Id</label>
+    <& /Elements/PopupHelp, key => 'My Topic' &>
+    <input type="text" name="ticketId" />
+
+or might create help tags dynamically based on a Custom Field called Category
+
+    % while (my $ticket = $tickets->Next) {
+    %   my $ctgy = $ticket->FirstCustomFieldValue("Category")
+    <h1><% $ticket->Subject %></h1><& /Elements/PopupHelp, $key => $ctgy &>
+    % }
+
+=head2 HTML Attributes
+
+Add C<data-help="My Topic"> and (optionally) C<data-content="The help content">
+attributes to any HTML elements.
+
+=over
+
+=item * C<data-help>
+Required. The name of the help topic. If C<data-content>
+is omitted, content will come from an Article with this Name.
+Used as the title of the popup dialog if C<data-title> is not supplied or if in
+asynchronous mode. See L</Async>.
+
+=item * C<data-title>
+Optional. The title to use for the popup dialog box. If omitted, C<data-help> will
+be used.
+
+=item * C<data-content>
+Optional. The help content. If omitted, asynchronous mode will be used to dynamically retrieve
+the help content. See L</Async>.
+
+=item * C<data-action>
+Optional. The action to use when adding the help icon to the DOM. Defaults to C<"append">. See
+L</Help Selector Rules> section for more details.
+
+=back
+
+=head3 Example
+
+A Mason template might add the C<data-help> attribute to an element along
+with some static help content that includes custom HTML
+
+    <button data-help="Save Widget"
+            data-content='Saves the <font color="red">Widget</font> to RT'
+            data-action="after">Save</button>
+
+Or we could omit the C<data-content> altogether to have RT return the help content from the
+matching C<"List Sprockets"> Article when the user clicks the help icon
+
+    <button data-help="List Sprockets" data-action="after">List</button>
+
+=head2 JavaScript
+
+Call C<addPopupHelpItems> to add one or more rules to the list of help topics on a page that
+should be decorated with help icons.
+
+The C<addPopupHelpItems> function populates the C<pagePopupHelpItems> array with a list of
+declarative rules that define elements in the DOM that should have associated help icons. If
+a rule's C<selector> key matches one or more elements, its C<action> key will 
+determine where a help icon should be added to the DOM with help content corresponding to
+the C<content> key or from a valid help article with the same name as the C<title> key.
+
+Any rules thus added will be picked up and processed on page load when all of the accumulated
+rules are executed when C<renderPopupHelpItems> is called (for example, in the C<Elements/Footer>
+component in the page footer).
+
+This includes the default rule
+
+    { selector: "[data-help]", action: helpify }
+
+which matches anything with a C<data-help> attribute and therefore powers the L</HTML Attributes>
+method.
+
+This method of using JavaScript allows for tremendous flexibly annotating the DOM with help items,
+even after it has been rendered--perhaps by other templates altogether, making it attractive as a
+mechanism for customers to annotate aspects of RT--however it has been installed for them, including
+any and all extensions--simply by inspecting what is rendered to the browser and writing the
+appropriate rules. Importantly, these rules can usually be added to I<one place> (e.g. in a page
+callback somewhere) so they do I<not> need to overlay virtually every template in RT just to
+add help icons throughout.
+
+Note that C<renderPopupHelpItems> does not consider the C<ShowInlineHelp> setting/user-preference because
+it is assumed that the server-side logic would already have omitted the JavaScript call altogether
+(e.g. via the C</Elements/PopupHelp> component) if C<ShowInlineHelp> was unset.
+
+=head3 Help Selector Rules
+
+A help selector rule is a JavaScript object with the following keys
+
+=over
+
+=item * C<selector> - I<String | Function>
+
+Required. Defines which DOM elements should receive a help icon. Can match 0, 1, or many elements.
+Selectors matching 0 elements have no impact on the DOM.
+
+=over
+
+=item * I<String>
+A JQuery selector string that defines the matching DOM elements
+
+=item * I<Function>
+
+A JavaScript function that will be passed an instance of the C<JQuery> object and should
+return a JQuery collection of matching DOM elements. That is, the function signature
+is C<function( jQuery ) { ... }>
+
+=back
+
+=item * C<title> - I<String | Array(String) | Function>
+
+Optional. The help topic(s) that should be associated with the element(s) matching the C<selector>
+
+=over
+
+=item * I<String>
+The name of the help topic that should be matched against the Article Name. If the C<selector>
+matches exactly one element, this will be its help topic. If more than one element are
+matched, they will all get this same help topic.
+
+=item * I<Array(String)>
+An array of help topic names. They will be applied in order corresponding to the elements
+returned by the C<selector>
+
+=item * I<Function>
+A JavaScript function that will be called with the elements matched by the C<selector> that
+should return the help topic for that element. That is, the function signagure is 
+C<function( $els ) { ... }>
+
+=back
+
+=item * C<content> - I<String | Array(String)>
+
+Optional. The help content to be displayed in the popup when the user clicks the help icon.
+
+If missing, asynchronous mode is automatically triggered (see L</Async>)
+
+=over
+
+=item * I<String>
+The help content. May contain HTML. Will be applied for all elements matched by C<selector>.
+
+=item * I<Array(String)>
+Each member of the array will be applied to each corresponding member of the array of 
+elements matched by C<selector>.
+
+=back
+
+=item * C<action> - I<String | Function>
+
+Optional. The action that should be taken with each help icon that results from the application
+of C<selector>. Responsible for actually adding the help icons to the DOM. This controls, for
+example, where the icon should be rendered relative to the matching DOM element.
+
+If missing, C<"after"> is the default.
+
+=over
+
+=item * I<String>
+A shortcut method for referencing a number of predefined action functions. The following values
+are supported:
+
+=over
+
+=item * I<before>
+The help icon will be prepended to the DOM I<before> the element(s) matched by C<selector>
+
+=item * I<after>
+Default. The help icon will be appended to the DOM I<after> the element(s) matched by C<selector>
+
+=item * I<append>
+The help icon will be appended to the end of the DOM element(s) matched by C<selector>
+
+=item * I<prepend>
+The help icon will be prepended to the beginning of the DOM element(s) matched by C<selector>
+
+=item * I<replace>
+The help icon will be inserted into the DOM I<in place of> the element(s) matched by C<selector>.
+This action is used, for example, by the C</Elements/PopupHelp> Mason component.
+
+=item * I<offset>
+The help icon will be offset from the element(s) matched by C<selector> by the amounts 
+communicated in C<actionArgs>. Works with the JQuery C<offset> method and takes an object
+parameter with coordinate keys C<{ top: 10, left: 20 }>
+
+=back
+
+=item * I<Function>
+A JavaScript function responsible for actually adding the help icons to the DOM. Will be called
+for each element matched by the C<selector>. The function signature is C<function( $el, rule, actionArgs )>
+
+=back
+
+=item * C<actionArgs> - Array
+Any additional arguments that should be passed to the C<action> function.
+
+=back
+
+=head3 Examples
+
+Add a help topic named C<"My Topic"> to the DOM element with an id of C<"ticket-id">
+
+    addPopupHelpItems(
+        {
+            selector: "#ticket-id",
+            title: "My Topic"
+        }
+    )
+
+Add a help topic named C<"Phone"> and custom HTML content to the DOM element with an id of C<"phone-nbr">
+
+    addPopupHelpItems(
+        {
+            selector: "#phone-nbr",
+            title: "Phone",
+            content: "The customer phone number. This <i>MUST</i> include the country code."
+        }
+    )
+
+Add more than one rule at a time
+
+    addPopupHelpItems(
+        { selector: "#ticket-status", title: "Status Topic" },
+        { selector: "#ticket-queue",  title: "Queue Topic"  }
+    )
+
+Add a help topic named C<"A Note on Submitting Forms"> to every C<E<lt>buttonE<gt>> element
+of type C<submit>.
+
+    addPopupHelpItems( { selector: "button[type='submit']", title: "A Note on Submitting Forms" } )
+
+Find every C<E<lt>divE<gt>> element with a C<"heading"> class, and add a help topic named 
+C<"One"> to the first one, C<"Two"> to the second one, and C<"Three"> to the third one.
+
+    addPopupHelpItems( { selector: "div.heading", title: [ "One", "Two", "Three" ]} )
+
+Use a custom C<selector> function to match divs that have ids starting with C<"ACME-"> but only when 
+not working locally in developer mode. Determine the article title from the matching ids by stripping
+off the C<"ACME-"> portion
+
+    var acmeDivs = function( jQuery ) {
+        if (location.hostname != "localhost") {
+            return jQuery("div").filter(function($el) { 
+                return $el.id.startsWith("ACME-")
+            })
+        }
+    }
+
+    var makeTitle = function( el ) {
+        return el.id.replace("ACME-", "")
+    }
+
+    addPopupHelpItems(
+        {
+            selector: acmeDivs,
+            title:    makeTitle
+        }
+    )
+
+Prepend help topics to all form radio buttons
+
+    addPopupHelpItems(
+        {
+            selector: "form input[type='radio']",
+            topic:    "Radio Button Help",
+            content:  "You can only select one at a time",
+            action:   "prepend"
+        }
+    )
+
+Provide help for every field in each section on a ticket display page, but place each
+help icon in a line at the top of its respective section. Use asynchronous mode for
+help content, using the field text as the help topic.
+
+    var sectionInsert = function( $els, rule, options ) {
+        $els.each(function(i,el) {
+            const $el = jQuery(el)
+            const $a = $el.closest(".titlebox").find(".titlebox-title.card-header a")
+            const fieldName = $el.text().replace(":", "")
+            $a.append( buildPopupHelpHtml( fieldName ) )
+        })
+    }
+
+    addPopupHelpItems(
+        {
+            selector: ".titlebox .card-body .form-row .label",
+            action:   sectionInsert
+        }
+    )
+
+=head2 Programmatic API
+
+The following functions are part of, and used by, the RT Help System. You can also call them
+directly from your code.
+
+=head3 RT::Interface::Web::GetSystemHelpClass( locales )
+
+Given a list of locales, find the best article class that has been associated with the
+C<"RT Help System"> custom field. Locales are searched in order. The first Class with an
+C<"RT Help System"> custom field and matching C<"Locale"> custom field will be returned.
+
+=head3 RT::Interface::Web::GetHelpArticleContent( class_id, article_name )
+
+Returns the raw, unscrubbed and unescaped C<Content> of an Article of the given Class.
+Often, the class_id will come from C<GetSystemHelpClass>, but it does not have to.
+
+=head2 Async
+
+In asynchronous mode, only the help topic is supplied when the page is rendered. Only when
+the user clicks on the help icon is the help content dynamically retrieved from the server
+with a second AJAX request to which will attempt to fetch the given help article contents.
+The contents are returned directly as an HTML fragment--that is, they are not wrapped in 
+a C<<html>> tag, for example.
+
+The AJAX call will be a request to C</Helpers/HelpTopic?key=MyTopic> which will return
+the raw contents of the C<MyTopic> Article, which may contain HTML. It will not be sanitized.
+If no valid C<MyTopic> help article exists (see L</OVERVIEW>), 
+
+    <div class="text-danger">No help was found for 'MyTopic'.</div>
+
+will be returned instead.
+
+The C</Helpers/HelpTopic> component does not consider the C<ShowInlineHelp> setting/user-preference.
+However, if C<ShowInlineHelp> is not set, the help icon would generally not have been rendered
+anyway, so the AJAX call would never have been made.
+
+Asynchronous mode does have the benefit of reducing the number of database calls that need
+to be made to retrieve help article content on page request, but the user may experience a 
+slight lag when the help icon is clicked and the AJAX request is being made. This will need
+to be evaluated on a case-by-case basis. On a heavily used RT system, the performance of pages
+with many help topics may benefit from using asynchronous mode more generously.
+
+=head1 NAMING
+
+Since the RT help system uses the help topic as the key to find a corresponding Article, it
+helps to have a somewhat predictable naming convention for help topics.
+
+=head2 RT objects
+
+In general, help topics for builtin RT functionality will be prefixed by C<"RT-">
+
+=over
+
+=item *
+RT-{The Name}
+
+=item *
+RT-{Context}-{The Name}
+
+=item *
+RT-{Path/To/Page}-{The Name}
+
+=item *
+RT-MainMenu-{}-{}-...
+
+=item *
+RT-PageMenu-{}-{}-...
+
+=back
+
+=head2 User-defined objects
+
+When you wish to dynamically create help topics based on the name of an object that the end
+users create, the following naming conventions can serve as a guide
+
+=over
+
+=item *
+User-Dashboard-{The Name}
+
+=item *
+System-Dashboard-{The Name}
+
+=item *
+CustomRole-{The Name}
+
+=item *
+SystemRole-{The Name}
+
+=item *
+CustomField-{The Name}
+
+=item *
+User-SavedSearch-{The Name}
+
+=item *
+{Group Name}-SavedSearch-{The Name}
+
+=item *
+System-SavedSearch-{The Name}
+
+=back
+
+=head1 DESIGN CONSIDERATIONS
+
+Choosing synchronous vs asynchronous mode involves several tradeoffs already discussed in
+L</Async>.
+
+In synchronous mode, there are also tradeoffs in choosing whether to provide content directly
+via the C<data-content> attribute or the C<content> property of a JavaScript help rule. It is
+often convenient to provide the help directly, especially if it has to be constructed in order
+to do so. However, this makes it much more difficult for end users to edit or customize the
+help content (since it now lives in code instead of an Article). It also makes it more 
+difficult to support multiple locales.
+
+Help authors should choose the method that best balances these considerations for their
+use case.
+
+=head1 INTERNATIONALIZATION
+
+The RT help system works with multiple languages by using Articles in Classes. Each Class should
+have a different value for its C<Locale> Custom Field. All of the articles in that Class should
+be in that language.
+
+=head2 Adding a new language
+
+=over
+
+=item *
+Add a Class (for example, via B<Admin E<gt> Articles E<gt> Classes E<gt> Create>). You can name
+the Class whatever you want, but something like "Help - <language>" is probaby a good idea for clarity.
+
+=item *
+Associate it with the RT Help System. Find the "RT Help System" Custom Field (for example,
+B<Admin E<gt> Global E<gt> Custom Fields E<gt> Classes>) and apply it to your new Class
+
+=item *
+Associate it with a language. Find the "Locale" Custom Field (for example,
+B<Admin E<gt> Global E<gt> Custom Fields E<gt> Classes>) and apply it to your new Class
+
+=item *
+Set the language. Find your new Class (for example, B<Admin E<gt> Classes E<gt> Select>) and
+set the "Locale" to your <language> in the dropdown
+
+=item *
+Add articles to your new Class
+
+=back
+
+=head1 BUGS
+
+Please report them to rt-bugs at bestpractical.com, if you know what's
+broken and have at least some idea of what needs to be fixed.
+
+If you're not sure what's going on, start a discussion in the RT
+Developers category on the community forum at
+<https://forum.bestpractical.com> or send email to
+sales at bestpractical.com for professional assistance.
+
+=head1 SEE ALSO
+
+Devel
diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 6286872212..1ef677ce39 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -2073,6 +2073,16 @@ our %META;
     SelfServiceRegex => {
         Immutable => 1,
     },
+    ShowInlineHelp => {
+        Section         => 'General',
+        Overridable     => 1,
+        Description     => 'Show Inline Help',
+        Widget          => '/Widgets/Form/Boolean',
+        WidgetArguments => {
+            Description => 'Show inline help?', # loc
+            Hints       => 'Displays icons for help topics' #loc
+        },
+    },
 );
 my %OPTIONS = ();
 my @LOADED_CONFIGS = ();
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index 859ceacd40..265b6a9f26 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -144,6 +144,7 @@ sub JSFiles {
         Chart.min.js
         chartjs-plugin-colorschemes.min.js
         jquery.jgrowl.min.js
+        popup-help.js
         }, RT->Config->Get('JSFiles');
 }
 
@@ -5210,7 +5211,6 @@ sub PreprocessTimeUpdates {
     RT::Interface::Web::PreprocessTimeUpdates(@_);
 }
 
-
 =head2 GetDashboards Objects => ARRAY, CurrentUser => CURRENT_USER
 
 Return available dashboards that are saved in the name of objects for
@@ -5289,6 +5289,101 @@ sub GetDashboards {
     return \%dashboards;
 }
 
+=head3 GetSystemHelpClass locales
+
+Given a list of locales, find the best article class that has been associated with the
+'RT Help System' custom field. Locales are searched in order. The first Class with an
+'RT Help System' custom field and matching 'Locale' custom field will be returned.
+
+=cut
+
+sub GetSystemHelpClass {
+    my $locales = shift || ['en'];
+
+    # Find the custom field that indicates a Class is participating in the RT Help System
+    my $cf = RT::CustomField->new( RT->SystemUser );
+    my ($ret, $msg) = $cf->Load("RT Help System");
+    unless ($ret and $cf->Id) {
+        RT::Logger->warn("Could not find custom field for 'RT Help System' $msg");
+        return;
+    }
+
+    # Loop over the supplied locales in order. Return the first Class that is participating
+    # in the RT Help System that also has a matching Locale custom field value
+    my $Classes = RT::Classes->new( RT->SystemUser );
+    ($ret, $msg) = $Classes->LimitCustomField( CUSTOMFIELD => $cf->Id, OPERATOR => "=", VALUE => "yes" );
+    if ($ret) {
+        for my $locale (@$locales) {
+            $Classes->GotoFirstItem;
+            while (my $class = $Classes->Next) {
+                my $val = $class->FirstCustomFieldValue('Locale');
+                return $class if $val eq $locale;
+            }
+        }
+    } else {
+        RT::Logger->debug("Could not find a participating help Class $msg");
+    }
+
+    # none found
+    RT::Logger->debug("Could not find a suitable help Class for locales: @$locales");
+    return;
+}
+
+=head3 GetHelpArticleTitle class_id, article_name
+
+Returns the value of the C<"Display Name"> Custom Field of an Article of the given Class.
+Often, the class_id will come from GetSystemHelpClass, but it does not have to.
+
+=cut
+
+sub GetHelpArticleTitle {
+    my $class_id = shift || return '';      # required
+    my $article_name = shift || return '';  # required
+
+    # find the article of the given class
+    my $Article = RT::Article->new( RT->SystemUser );
+    my ($ret, $msg) = $Article->LoadByCols( Name => $article_name, Class => $class_id, Disabled => 0 );
+    if ( $Article and $Article->Id ) {
+        return $Article->FirstCustomFieldValue('Display Name') || '';
+    }
+
+    # no match was found
+    RT::Logger->debug("No help article found for '$article_name'");
+    return '';
+}
+
+=head3 GetHelpArticleContent class_id, article_name
+
+Returns the raw, unscrubbed and unescaped Content of an Article of the given Class.
+Often, the class_id will come from GetSystemHelpClass, but it does not have to.
+
+=cut
+
+sub GetHelpArticleContent {
+    my $class_id = shift || return '';      # required
+    my $article_name = shift || return '';  # required
+
+    # find the article of the given class
+    my $Article = RT::Article->new( RT->SystemUser );
+    my ($ret, $msg) = $Article->LoadByCols( Name => $article_name, Class => $class_id, Disabled => 0 );
+    if ( $Article and $Article->Id ) {
+        RT::Logger->debug("Found help article id: " . $Article->Id);
+        my $class = $Article->ClassObj;
+        my $cfs = $class->ArticleCustomFields;
+        while (my $cf = $cfs->Next) {
+            if ($cf->Name eq 'Content') {
+                my $ocfvs = $Article->CustomFieldValues($cf->Id);
+                my $ocfv = $ocfvs->First;
+                return $ocfv->Content;  # do not escape
+            }
+        }
+    }
+
+    # no match was found
+    RT::Logger->debug("No help article found for '$article_name'");
+    return '';
+}
+
 package RT::Interface::Web;
 RT::Base->_ImportOverlays();
 
diff --git a/share/html/Elements/Footer b/share/html/Elements/Footer
index 9884c963d7..333ade7c39 100644
--- a/share/html/Elements/Footer
+++ b/share/html/Elements/Footer
@@ -79,6 +79,14 @@
 % }
     </div>
   </body>
+<script>
+jQuery(document).ready(function() {
+    // popup-help.js : any help items that have been queued up via addPopupHelpItems() will
+    // get their popover functionality added at this point, including the default rule
+    // that matches any elements with a 'data-help' attribute
+    renderPopupHelpItems()
+})
+</script>
 </html>
 <%ARGS>
 $Debug => 0
diff --git a/share/html/Elements/Footer b/share/html/Elements/PopupHelp
similarity index 58%
copy from share/html/Elements/Footer
copy to share/html/Elements/PopupHelp
index 9884c963d7..0f801e42bb 100644
--- a/share/html/Elements/Footer
+++ b/share/html/Elements/PopupHelp
@@ -44,43 +44,25 @@
 %# works based on those contributions, and sublicense and distribute
 %# those contributions and any derivatives thereof.
 %#
-%# END BPS TAGGED BLOCK }}}
-%# End of div#body from /Elements/PageLayout
-  <hr class="clear" />
-</div>
-% $m->callback( %ARGS );
-<div id="footer" title="Best Practical Solutions, LLC, copyright" class="row">
-% # display 3 columns on login page
-% # display 1 column center aligned once logged in, without the "For support and sales..." section
-% my $cols = ( $Menu ? '12' : '4' );
-  <div class="col-<% $cols %>">
-    <p id="version" class="text-center <% $Menu ? 'pb-1' : 'text-md-left' %>">
-%     if ($m->{'rt_base_time'}) {
-        <span id="time"><&|/l&>Time to display</&>: <%Time::HiRes::tv_interval( $m->{'rt_base_time'} )%> -</span>
-%     }
-      <span><&|/l_unsafe, $RT::VERSION, &>RT Version [_1]</&></span>
-    </p>
-  </div>
-  <div class="col-<% $cols %>">
-    <p id="bpscredits" class="text-center"><span><&|/l_unsafe, '2021', '»|«', '<a href="https://bestpractical.com/about">Best Practical Solutions, LLC</a>', &>Copyright 1996-[_1] [_2] [_3].</&></span></p>
-  </div>
-% if (!$Menu) {
-  <div class="col-4">
-    <p id="legal" class="text-md-right text-center"><&|/l_unsafe, '<a href="mailto:sales at bestpractical.com">sales at bestpractical.com</a>' &>For support and sales inquiries, please contact [_1].</&></p>
-  </div>
+<%args>
+$key => ''
+</%args>
+<%init>
+my $has_help;
+my $help_class;
+my $help_content;
+my $help_title;
+if ($key) {
+    my $lh = $session{'CurrentUser'}->LanguageHandle;
+    my @locs = ( $lh->language_tag(), $lh->fallback_languages() );
+    my $help_class = GetSystemHelpClass( \@locs );
+    if ($help_class && $help_class->Id) {
+        $help_title = GetHelpArticleTitle( $help_class->id, $key ) || $key;
+        $help_content = GetHelpArticleContent( $help_class->Id, $key );
+    }
+    $has_help = $help_content;
+}
+</%init>
+% if (RT->Config->Get('ShowInlineHelp', $session{CurrentUser}) && $has_help) {
+<span data-help="<% $help_title %>" data-content="<% $help_content %>" data-action="replace" style="display: none;"/>
 % }
-</div>
-% if ($Debug >= 2 ) {
-% require Data::Dumper;
-% my $d = Data::Dumper->new([\%ARGS], [qw(%ARGS)]);
-<pre>
-<%$d->Dump() %>
-</pre>
-% }
-    </div>
-  </body>
-</html>
-<%ARGS>
-$Debug => 0
-$Menu => 1
-</%ARGS>
diff --git a/share/html/Elements/Footer b/share/html/Helpers/HelpTopic
similarity index 58%
copy from share/html/Elements/Footer
copy to share/html/Helpers/HelpTopic
index 9884c963d7..7e304eb75e 100644
--- a/share/html/Elements/Footer
+++ b/share/html/Helpers/HelpTopic
@@ -45,42 +45,18 @@
 %# those contributions and any derivatives thereof.
 %#
 %# END BPS TAGGED BLOCK }}}
-%# End of div#body from /Elements/PageLayout
-  <hr class="clear" />
-</div>
-% $m->callback( %ARGS );
-<div id="footer" title="Best Practical Solutions, LLC, copyright" class="row">
-% # display 3 columns on login page
-% # display 1 column center aligned once logged in, without the "For support and sales..." section
-% my $cols = ( $Menu ? '12' : '4' );
-  <div class="col-<% $cols %>">
-    <p id="version" class="text-center <% $Menu ? 'pb-1' : 'text-md-left' %>">
-%     if ($m->{'rt_base_time'}) {
-        <span id="time"><&|/l&>Time to display</&>: <%Time::HiRes::tv_interval( $m->{'rt_base_time'} )%> -</span>
-%     }
-      <span><&|/l_unsafe, $RT::VERSION, &>RT Version [_1]</&></span>
-    </p>
-  </div>
-  <div class="col-<% $cols %>">
-    <p id="bpscredits" class="text-center"><span><&|/l_unsafe, '2021', '»|«', '<a href="https://bestpractical.com/about">Best Practical Solutions, LLC</a>', &>Copyright 1996-[_1] [_2] [_3].</&></span></p>
-  </div>
-% if (!$Menu) {
-  <div class="col-4">
-    <p id="legal" class="text-md-right text-center"><&|/l_unsafe, '<a href="mailto:sales at bestpractical.com">sales at bestpractical.com</a>' &>For support and sales inquiries, please contact [_1].</&></p>
-  </div>
-% }
-</div>
-% if ($Debug >= 2 ) {
-% require Data::Dumper;
-% my $d = Data::Dumper->new([\%ARGS], [qw(%ARGS)]);
-<pre>
-<%$d->Dump() %>
-</pre>
-% }
-    </div>
-  </body>
-</html>
-<%ARGS>
-$Debug => 0
-$Menu => 1
-</%ARGS>
+<%args>
+$key => '',
+</%args>
+<%init>
+my $help_class;
+my $help_content;
+if ($key) {
+    my $lh = $session{'CurrentUser'}->LanguageHandle;
+    my @locs = ( $lh->language_tag(), $lh->fallback_languages() );
+    my $help_class = GetSystemHelpClass( \@locs );
+    $help_content = GetHelpArticleContent( $help_class->Id, $key ) || "<div class='text-danger'>No help was found for '$key'.</div>";
+    $m->print($help_content);
+}
+$m->abort;
+</%init>
diff --git a/share/static/css/elevator-dark/main.css b/share/static/css/elevator-dark/main.css
index 7ea673931b..08cc59b5a9 100644
--- a/share/static/css/elevator-dark/main.css
+++ b/share/static/css/elevator-dark/main.css
@@ -453,3 +453,11 @@ body.darkmode.IE11 .ui-icon-circle-triangle-e {
     border-color: #717171;
     margin-bottom: 1px;
 }
+
+.darkmode .popover {
+    border: 1px solid rgba(255, 255, 255, 0.6);
+}
+
+.darkmode .popover-header {
+    border-bottom: 1px solid rgba(255, 255, 255, 0.6);
+}
diff --git a/share/static/images/question-white.svg b/share/static/images/question-white.svg
new file mode 100644
index 0000000000..c2bcf3c76b
--- /dev/null
+++ b/share/static/images/question-white.svg
@@ -0,0 +1,67 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   viewBox="0 0 16 16"
+   width="16"
+   height="16"
+   version="1.1"
+   id="svg163"
+   sodipodi:docname="question-white.svg"
+   inkscape:version="1.0.1 (c497b03c, 2020-09-10)">
+  <metadata
+     id="metadata169">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title></dc:title>
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs167" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1652"
+     inkscape:window-height="980"
+     id="namedview165"
+     showgrid="false"
+     inkscape:zoom="45.9375"
+     inkscape:cx="8"
+     inkscape:cy="8"
+     inkscape:window-x="0"
+     inkscape:window-y="105"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="svg163" />
+  <path
+     fill-rule="evenodd"
+     d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zM6.92 6.085c.081-.16.19-.299.34-.398.145-.097.371-.187.74-.187.28 0 .553.087.738.225A.613.613 0 019 6.25c0 .177-.04.264-.077.318a.956.956 0 01-.277.245c-.076.051-.158.1-.258.161l-.007.004a7.728 7.728 0 00-.313.195 2.416 2.416 0 00-.692.661.75.75 0 001.248.832.956.956 0 01.276-.245 6.3 6.3 0 01.26-.16l.006-.004c.093-.057.204-.123.313-.195.222-.149.487-.355.692-.662.214-.32.329-.702.329-1.15 0-.76-.36-1.348-.863-1.725A2.76 2.76 0 008 4c-.631 0-1.155.16-1.572.438-.413.276-.68.638-.849.977a.75.75 0 101.342.67z"
+     id="path161" />
+  <path
+     style="fill:#ffffff;stroke-width:0.0217687"
+     d="M 7.4993197,15.975617 C 5.5754804,15.837138 3.8106789,15.05408 2.4519541,13.736054 1.2836555,12.602751 0.49210782,11.163817 0.17464509,9.5961918 0.05430088,9.0019344 0.02562137,8.6947483 0.02562137,8 c 0,-0.6947483 0.02867951,-1.0019344 0.14902372,-1.5961918 C 0.39917024,5.295107 0.90701002,4.1589166 1.5863609,3.2453745 2.3661281,2.1967998 3.3678541,1.3724795 4.5608044,0.79770523 5.3536555,0.41570238 6.1604517,0.18158567 7.0965986,0.06186653 7.5564763,0.00305508 8.4992529,0.0076737 8.9687075,0.0710379 9.9095993,0.1980339 10.660007,0.42082656 11.472109,0.81428608 12.278147,1.2048081 12.92108,1.653682 13.548236,2.2637684 c 0.697339,0.6783577 1.185766,1.350316 1.61408,2.2205853 0.410028,0.8331146 0.634117,1.5751902 0.765807,2.5359887 0.06685,0.4877257 0.06685,1.4715895 0,1.9593152 -0.127122,0.9274706 -0.350941,1.6832734 -0.731652,2.4706824 -0.397248,0.821611 -0.852464,1.472541 -1.482119,2.119336 -0.657719,0.675622 -1.326983,1.162215 -2.176937,1.582755 -0.833431,0.412365 -1.6236
 167,0.651431 -2.5562068,0.773365 -0.3279267,0.04288 -1.1802713,0.07153 -1.4818885,0.04982 z m 1.3704251,-1.521208 c 2.3480742,-0.323949 4.3269062,-1.876342 5.1889572,-4.070736 0.195317,-0.4971898 0.322038,-0.9843644 0.399684,-1.5365784 0.05336,-0.3795256 0.05336,-1.3146636 0,-1.6941892 C 14.297659,6.009824 13.872334,4.971535 13.20027,4.0816327 12.253712,2.8282634 10.902251,1.9647631 9.3695436,1.6340349 8.8912239,1.5308228 8.6174502,1.5042248 8.0217687,1.5030943 7.4346266,1.50198 7.205573,1.5214905 6.7337752,1.6128041 4.3802227,2.0683199 2.4291291,3.8488897 1.754131,6.1572321 1.5891263,6.721511 1.517708,7.1814095 1.4983891,7.8040816 1.4520964,9.296157 1.9003863,10.722822 2.7856189,11.90064 c 0.9704233,1.291166 2.380098,2.178047 3.9517961,2.48623 0.2361541,0.04631 0.4130594,0.07057 0.7945578,0.108982 0.1927645,0.01941 1.1005307,-0.0087 1.337772,-0.04144 z"
+     id="path171" />
+  <path
+     style="fill:#ffffff;stroke-width:0.0217687"
+     d="M 7.7118248,11.941402 C 7.4512974,11.858118 7.220616,11.654074 7.1006254,11.40078 c -0.065428,-0.138114 -0.069333,-0.160459 -0.069333,-0.396698 0,-0.244195 0.002,-0.25452 0.081302,-0.420611 0.3614811,-0.7570487 1.4133305,-0.7570487 1.7748116,0 0.079306,0.166091 0.081302,0.176416 0.081302,0.420611 0,0.236506 -0.00385,0.25847 -0.069692,0.397464 -0.078421,0.165554 -0.2526893,0.362806 -0.3933678,0.445249 -0.23099,0.135369 -0.5480917,0.173161 -0.7938234,0.09461 z"
+     id="path173" />
+  <path
+     style="fill:#ffffff;stroke-width:0.0217687"
+     d="M 7.7893699,8.9495595 C 7.5879097,8.8836778 7.4260958,8.7362527 7.3325199,8.5333333 7.2838245,8.4277375 7.2721956,8.3712514 7.2718047,8.2384163 7.2711218,8.0063767 7.3496573,7.8533701 7.6113976,7.5768054 7.8120712,7.364766 8.0121985,7.2174045 8.5275409,6.9022145 8.7626808,6.7583996 8.9241062,6.6122318 8.9705493,6.5010778 9.0216242,6.3788386 9.0107429,6.0944374 8.9500245,5.9646259 8.8140763,5.6739773 8.4424893,5.4972422 7.9673469,5.4972422 7.470926,5.4972422 7.1708884,5.6497007 6.949601,6.0143907 6.8080869,6.2476114 6.7148448,6.3432007 6.5500735,6.4239756 6.4406494,6.4776181 6.3961418,6.4867984 6.247619,6.4863613 6.0336827,6.4857321 5.8719749,6.4193406 5.7312579,6.2743639 5.5874518,6.1262045 5.5313848,5.9963717 5.5222959,5.7904762 c -0.00986,-0.2233914 0.0331,-0.3420804 0.2335917,-0.6453389 0.4855836,-0.7344821 1.264948,-1.1289757 2.229949,-1.1287412 1.1725236,2.848e-4 2.1268494,0.6481989 2.4206964,1.6434678 0.08266,0.2799802 0.09834,0.7361248 0.0357,1.0384127 C 10.364633,7.0
 727212 10.20693,7.3733353 9.937419,7.6605632 9.7610717,7.8484983 9.5555692,8.0022232 9.1350494,8.2607678 8.8094537,8.4609509 8.7375549,8.5184797 8.5394989,8.7372889 8.404372,8.8865748 8.2410956,8.9623946 8.0338438,8.9720971 c -0.090733,0.00425 -0.190767,-0.00497 -0.2444739,-0.022538 z"
+     id="path175" />
+</svg>
diff --git a/share/static/images/question.svg b/share/static/images/question.svg
new file mode 100644
index 0000000000..89ebf0e2fc
--- /dev/null
+++ b/share/static/images/question.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zM6.92 6.085c.081-.16.19-.299.34-.398.145-.097.371-.187.74-.187.28 0 .553.087.738.225A.613.613 0 019 6.25c0 .177-.04.264-.077.318a.956.956 0 01-.277.245c-.076.051-.158.1-.258.161l-.007.004a7.728 7.728 0 00-.313.195 2.416 2.416 0 00-.692.661.75.75 0 001.248.832.956.956 0 01.276-.245 6.3 6.3 0 01.26-.16l.006-.004c.093-.057.204-.123.313-.195.222-.149.487-.355.692-.662.214-.32.329-.702.329-1.15 0-.76-.36-1.348-.863-1.725A2.76 2.76 0 008 4c-.631 0-1.155.16-1.572.438-.413.276-.68.638-.849.977a.75.75 0 101.342.67z"></path></svg>
\ No newline at end of file
diff --git a/share/static/js/popup-help.js b/share/static/js/popup-help.js
new file mode 100644
index 0000000000..2374bc5561
--- /dev/null
+++ b/share/static/js/popup-help.js
@@ -0,0 +1,183 @@
+// a list of entries to process for the page
+var pagePopupHelpItems = [
+	{ selector: "[data-help]", action: helpify }  // by default, anything with data-help attributes gets processed
+]
+
+// add one or more items to the list of help entries to process for the page
+function addPopupHelpItems() {
+	const args = [].slice.call(arguments).reduce(function(acc,val) { return acc.concat(val) }, [] )
+	pagePopupHelpItems = pagePopupHelpItems || []
+	pagePopupHelpItems = pagePopupHelpItems.concat(args)
+}
+
+function applySelectorQueryOrFunc( sel ) {
+	if ( sel ) {
+		if ( typeof(sel) === "string" ) {
+			return jQuery(sel)
+		} else if ( typeof(sel) === "function" ) {
+			return sel(jQuery)
+		}
+	}
+}
+
+function getPopupHelpAction( entry={} ) {
+	entry.action = entry.action || "append"
+	if ( typeof(entry.action) === "string" ) {
+		const funcMap = {
+			"before": beforePopupHelp,
+			"after": afterPopupHelp,
+			"append": appendPopupHelp,
+			"prepend": prependPopupHelp,
+			"offset": offsetPopupHelp,
+			"replace": replacePopupHelp
+		}
+		if (funcMap.hasOwnProperty(entry.action)) {
+			return funcMap[entry.action]
+		} else {
+			console.error("Unknown action '" + entry.action + "' using 'after' instead")
+			return funcMap.after
+		}
+	} else if ( typeof(entry.action) === "function" ) {
+		return entry.action
+	}
+}
+
+function getPopupHelpActionArgs( entry={}, $els ) {
+	return entry.actionArgs ? [ $els, entry, entry.actionArgs ] : [ $els, entry ]
+}
+
+function beforePopupHelp( $els, item={}, options={} ) {
+	item.action = options.action = "before"
+	return helpify( $els, item, options )
+}
+
+function afterPopupHelp( $els, item={}, options={} ) {
+	item.action = options.action = "after"
+	return helpify( $els, item, options )
+}
+
+function appendPopupHelp( $els, item={}, options={} ) {
+	item.action = options.action = "append"
+	return helpify( $els, item, options )
+}
+
+function prependPopupHelp( $els, item={}, options={} ) {
+	item.action = options.action = "prepend"
+	return helpify( $els, item, options )
+}
+
+function offsetPopupHelp( $els, item={}, options={} ) {
+	item.action = options.action = "offset"
+	return helpify( $els, item, options )
+}
+
+function replacePopupHelp( $els, item={}, options={} ) {
+	item.action = options.action = "replace"
+	return helpify( $els, item, options )
+}
+
+function helpify($els, item={}, options={}) {
+	$els.each(function(index) {
+		const $el = jQuery($els.get(index))
+		const action = $el.data("action") || item.action || options.action
+		const title = $el.data("title") || item.title || $el.data("help")
+		const content = $el.data("content") || item.content
+		switch(action) {
+			case "before":
+				$el.before( buildPopupHelpHtml( title, content ) )
+				break
+			case "prepend":
+				$el.prepend( buildPopupHelpHtml( title, content ) )
+				break
+			case "offset":
+				$el.append( buildPopupHelpHtml( title, content ) ).offset( options )
+				break
+			case "replace":
+				$el.replaceWith( buildPopupHelpHtml( title, content ) )
+				break
+			case "append":
+				$el.append( buildPopupHelpHtml( title, content ) )
+				break
+			case "after":  // intentionally fallthrough, as 'after' is the default
+			default:
+				$el.parent().append( buildPopupHelpHtml( title, content ) )
+		}
+	})
+}
+
+function buildPopupHelpHtml(title, content) {
+	// TODO configurable glyph
+	var icon = '/static/images/question'
+	icon += (RT.Config.WebDefaultStylesheet.match(/-dark$/)) ? '-white.svg' : '.svg'
+	const contentAttr = content ? ' data-content="' + content + '" ' : ''
+	return '<a class="popup-help" tabindex="0" role="button" data-toggle="popover" title="' + title + '" data-trigger="focus" ' + contentAttr + '><img src="' + icon + '" /></a>'
+}
+
+function applyPopupHelpAction( entry, $els ) {
+	if ( entry ) {
+		const fn = getPopupHelpAction( entry )
+		const args = getPopupHelpActionArgs( entry, $els )
+		fn.apply(this, args)
+	}
+}
+
+// Dynamically load the help topic corresponding to a DOM element using AJAX
+// Should be called with the DOM element as the 'this' context of the function,
+// making it directly compatible with the 'content' property of the popper.js
+// popover() method, which is its primary purpose
+const popupHelpAjax = function() {
+    const isDefined = function(x) { return typeof x !== "undefined" }
+    const buildUrl = function(key) { return RT.Config.WebHomePath + "/Helpers/HelpTopic?key=" + encodeURIComponent(key) }
+    const boolVal = function(str) {
+        try {
+            return !!JSON.parse(str)
+        }
+        catch {
+            return false
+        }
+    }
+
+    const $el = jQuery(this)
+    const key = $el.data("help") || $el.data("title") || $el.data("originalTitle")
+    var content = $el.data("content")
+    if (content) {
+        return content
+    } else {
+        const isAsync = isDefined($el.data("async")) ? boolVal($el.data("async")) : true
+        if (isAsync) {
+            const tmpId = "tmp-id-" + jQuery.now()
+            jQuery.ajax({
+                url: buildUrl(key), dataType: "html",
+                dataType: "html",
+                success: function(response, statusText, xhr) {
+                    jQuery("#" + tmpId).html(xhr.responseText)
+                },
+                error: function(e) {
+                    jQuery("#" + tmpId).html("<div class='text-danger'>Error loading help for '" + key + "': " + e)
+                }
+            })
+            return "<div id='" + tmpId + "'>Loading...</div>"
+        } else {
+            return "<div class='text-danger'>No help content available for '" + key + "'.</div>"
+        }
+    }
+}
+
+// render all the help icons and popover-ify them
+function renderPopupHelpItems( list ) {
+    list = list || pagePopupHelpItems
+    if (list && Array.isArray(list) && list.length) {
+        list.forEach(function(entry) {
+            console.log("processing entry:", entry)
+            const $els = applySelectorQueryOrFunc(entry.selector)
+            if ( $els ) {
+                applyPopupHelpAction( entry, $els )
+            }
+        })
+        jQuery('[data-toggle="popover"]').popover({
+            trigger: 'focus',
+            html: true,
+            content: popupHelpAjax
+        })
+    }
+}

-----------------------------------------------------------------------


hooks/post-receive
-- 
rt


More information about the rt-commit mailing list