Repository: civicrm/civicrm-core
Pull request #32291: dev/core#5770 SK edit-in-place: focus appropriately when inline editing is initiated
Author: login: highfalutin
Labels: name: master
name: merge ready
description: PR will be merged after a few days if there are no objections
Created: 2025-03-04T19:47:34Z | Merged: 2025-03-13T21:55:50Z

Overview
----------------------------------------
Fixes [dev/core#5770](https://lab.civicrm.org/dev/core/-/issues/5770), at least partially - Fewer mouse clicks/keystrokes required to start editing in place in SearchKit.

Before
----------------------------------------
To actually get to a live cursor in a text field, or open the date picker, etc, multiple clicks/keystrokes are required.

After
----------------------------------------
In a table display with in-place-edit fields,
 
- clicking on an editable field immediately brings focus to that particular field ("focus" means a blinking cursor, an open datepicker, etc as appropriate)
- this works in "single field" in-place-edit mode as well as "full row" mode
- it works with text, integer, boolean (radio, checkbox), select2 (single, whether static or entityref) and datepicker widgets. 

Technical Details
----------------------------------------
My first attempt here feels slightly heavy and stiff, with hard-coded if-else blocks which probably could be refactored so that new/custom widget types could be supported, other display types could be supported, etc. But it's a step in the right direction.

Comments
----------------------------------------
- it currently only works on table displays, afaik
- it doesn't currently seem to work with select2 multi-select widgets
- other widgets are untested


Diff:
diff --git a/ext/search_kit/ang/crmSearchDisplay/colType/editable.html b/ext/search_kit/ang/crmSearchDisplay/colType/editable.html
index 323bbfff36ac..b2e08fd2d3bd 100644
--- a/ext/search_kit/ang/crmSearchDisplay/colType/editable.html
+++ b/ext/search_kit/ang/crmSearchDisplay/colType/editable.html
@@ -3,6 +3,7 @@
   display="$ctrl"
   is-full-row-mode="$ctrl.settings.editableRow.full"
   col-key="$ctrl.settings.columns[colIndex].key"
+  col-data="colData"
   ng-if="!colData.loading && $ctrl.editing === row.key && colData.editing"
 ></crm-search-display-editable>
 <span
diff --git a/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js b/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
index 6e359da243b2..a182a05c6d7e 100644
--- a/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
+++ b/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
@@ -9,6 +9,7 @@
       row: '<?',
       display: '<',
       colKey: '<',
+      colData: '<?',
       isFullRowMode: '<',
     },
     templateUrl: '~/crmSearchDisplay/crmSearchDisplayEditable.html',
diff --git a/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.html b/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.html
index cef208015745..bda2d2ce9560 100644
--- a/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.html
+++ b/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.html
@@ -1,5 +1,5 @@
 <form name="crmSearchDisplayEditableForm">
-  <crm-search-input class="form-inline" field="$ctrl.field" ng-model="$ctrl.display.editValues[$ctrl.colKey]"></crm-search-input>
+  <crm-search-input class="form-inline" field="$ctrl.field" ng-model="$ctrl.display.editValues[$ctrl.colKey]" crm-search-input-focus="{{:: $ctrl.colData.focused }}"></crm-search-input>
   <div class="form-inline crm-search-display-editable-buttons" ng-if="!$ctrl.isFullRowMode">
     <button type="submit" ng-disabled="!crmSearchDisplayEditableForm.$valid" ng-click="$ctrl.save()" class="btn btn-xs btn-success">
       <span class="sr-only">{{:: ts('Save') }}</span>
diff --git a/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayEditableTrait.service.js b/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayEditableTrait.service.js
index 2186dab9c34b..26cc8f86eb94 100644
--- a/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayEditableTrait.service.js
+++ b/ext/search_kit/ang/crmSearchDisplay/traits/searchDisplayEditableTrait.service.js
@@ -19,6 +19,7 @@
           this.editing = row.key;
           row.columns.forEach((col, index) => {
             col.editing = (index === colIndex || (this.settings.editableRow && this.settings.editableRow.full));
+            col.focused = index === colIndex;
           });
         }
       },
diff --git a/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputFocus.directive.js b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputFocus.directive.js
new file mode 100644
index 000000000000..14dcdd229d73
--- /dev/null
+++ b/ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputFocus.directive.js
@@ -0,0 +1,87 @@
+(function(angular, $) {
+  "use strict";
+
+  angular.module('crmSearchTasks').directive('crmSearchInputFocus', function($timeout) {
+    function waitForElement(selector, context = document) {
+      return new Promise((resolve) => {
+        const observer = new MutationObserver((mutations, observer) => {
+          const match = $(selector, context);
+          if (match.length) {
+            observer.disconnect();
+            resolve(match);
+          }
+        });
+
+        observer.observe(context, {
+          childList: true,
+          subtree: true,
+        });
+      });
+    }
+
+    return {
+      link: function(scope, element, attrs) {
+        function getFocusableInput() {
+          const types = new Map ([
+            ['select2', 'input[crm-ui-select], input[crm-autocomplete]'],
+            ['datepicker', 'input[crm-ui-datepicker]'],
+            ['boolean', 'input[type="radio"], input[type="checkbox"]'],
+            ['default', ':focusable']
+          ]);
+          for (const [type, selector] of types) {
+            const jQueryObject = $(selector, element);
+            if (jQueryObject.length > 0 || type === 'default') {
+              return [jQueryObject, type];
+            }
+          }
+        }
+
+        function focusOn() {
+          let [jQueryObject, type] = getFocusableInput();
+
+          if (type === 'default') {
+            jQueryObject.first().trigger('focus');
+          }
+
+          else if (type === 'boolean') {
+            // focus on the checked option, if any
+            let checkedItem = $(':checked', element);
+            if (checkedItem.length) {
+              jQueryObject = checkedItem;
+            }
+            jQueryObject.first().trigger('focus');
+          }
+
+          else if (type === 'select2') {
+            // the widget isn't built yet; wait for it to exist
+            waitForElement('.select2-choice', element[0]).then((elements) => {
+              $timeout(() => {
+                const container = elements.first().closest('.select2-container');
+                // this takes care of some non-ajax select2s
+                container.select2('open');
+                // for ajax select2s, wait until the selected option is rendered
+                $(element[0]).one('initSelectionComplete', () => {
+                  container.select2('open');
+                });
+              });
+            });
+          }
+
+          else if (type === 'datepicker') {
+            // the widget isn't built yet; wait for it to exist
+            waitForElement('.hasDatepicker', element[0]).then((elements) => {
+              elements.first().datepicker('show');
+            });
+          }
+        }
+
+        scope.$watch(attrs.crmSearchInputFocus, function(flag) {
+          if(flag === true) {
+            $timeout(focusOn);
+          }
+        });
+      }
+    };
+  });
+
+})(angular, CRM.$);
diff --git a/js/Common.js b/js/Common.js
index 0b049287a177..0364f3bbfb2f 100644
--- a/js/Common.js
+++ b/js/Common.js
@@ -637,10 +637,12 @@ if (!CRM.vars) CRM.vars = {};
           // If we already have the data, just return it
           if (!idsNeeded.length) {
             callback(multiple ? existing : existing[0]);
+            $el.trigger('initSelectionComplete');
           } else {
             var params = $.extend({}, getApiParams(), {ids: idsNeeded});
             CRM.api4(entityName, 'autocomplete', params).then(function (result) {
               callback(multiple ? result.concat(existing) : result[0]);
+              $el.trigger('initSelectionComplete');
             });
           }
         },


Reviews:
user: login: colemanw
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T21:52:01Z
user: login: colemanw
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T21:52:17Z
user: login: highfalutin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T19:02:44Z

Review comments:
diff_hunk: @@ -641,6 +641,7 @@ if (!CRM.vars) CRM.vars = {};
             var params = $.extend({}, getApiParams(), {ids: idsNeeded});
             CRM.api4(entityName, 'autocomplete', params).then(function (result) {
               callback(multiple ? result.concat(existing) : result[0]);
+              $el.trigger('initSelectionComplete');
path: js/Common.js
user: login: colemanw
body: For consistency, you probably should trigger this on L639 as well
created_at: 2025-03-05T21:52:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+(function(angular, $) {
+  "use strict";
+
+  angular.module('crmSearchTasks').directive('crmSearchInputFocus', function($timeout) {
+    function waitForElement(selector, context = document) {
+      return new Promise((resolve) => {
+        const observer = new MutationObserver((mutations, observer) => {
+          const match = $(selector, context);
+          if (match.length) {
+            observer.disconnect();
+            resolve(match);
+          }
+        });
+
+        observer.observe(context, {
+          childList: true,
+          subtree: true,
+        });
+      });
+    }
+
+    return {
+      link: function(scope, element, attrs) {
+        function getFocusableInput() {
+          const types = new Map ([
+            ['select2', 'input[crm-ui-select], input[crm-autocomplete]'],
+            ['datepicker', 'input[crm-ui-datepicker]'],
+            ['boolean', 'input[type="radio"], input[type="checkbox"]'],
+            ['default', ':focusable']
+          ]);
+          for (const [type, selector] of types) {
+            const jQueryObject = $(selector, element);
+            if (jQueryObject.length > 0 || type === 'default') {
+              return [jQueryObject, type];
+            }
+          }
+        }
+
+        function focusOn() {
+          scope.manualFocus = true;
+          let [jQueryObject, type] = getFocusableInput();
+
+          if (type === 'default') {
+            jQueryObject.first().trigger('focus');
+          }
+
+          else if (type === 'boolean') {
+            // focus on the checked option, if any
+            let checkedItem = $(':checked', element);
+            if (checkedItem.length) {
+              jQueryObject = checkedItem;
+            }
+            jQueryObject.first().trigger('focus');
+          }
+
+          else if (type === 'select2') {
+            // the widget isn't built yet; wait for it to exist
+            waitForElement('.select2-choice', element[0]).then((elements) => {
+              $timeout(() => {
+                const container = elements.first().closest('.select2-container');
+                // this takes care of some non-ajax select2s
+                container.select2('open');
+                // for ajax select2s, wait until the selected option is rendered
+                $(element[0]).one('initSelectionComplete', () => {
+                  container.select2('open');
+                });
+              });
+            });
+          }
+
+          else if (type === 'datepicker') {
+            // the widget isn't built yet; wait for it to exist
+            waitForElement('.hasDatepicker', element[0]).then((elements) => {
+              elements.first().datepicker('show');
+            });
+          }
+        }
+
+        function removeFocus() {
+          getFocusableInput().trigger('blur');
+        }
path: ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputFocus.directive.js
user: login: colemanw
body: Unused function?
created_at: 2025-03-05T21:52:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,96 @@
+(function(angular, $) {
+  "use strict";
+
+  angular.module('crmSearchTasks').directive('crmSearchInputFocus', function($timeout) {
+    function waitForElement(selector, context = document) {
+      return new Promise((resolve) => {
+        const observer = new MutationObserver((mutations, observer) => {
+          const match = $(selector, context);
+          if (match.length) {
+            observer.disconnect();
+            resolve(match);
+          }
+        });
+
+        observer.observe(context, {
+          childList: true,
+          subtree: true,
+        });
+      });
+    }
+
+    return {
+      link: function(scope, element, attrs) {
+        function getFocusableInput() {
+          const types = new Map ([
+            ['select2', 'input[crm-ui-select], input[crm-autocomplete]'],
+            ['datepicker', 'input[crm-ui-datepicker]'],
+            ['boolean', 'input[type="radio"], input[type="checkbox"]'],
+            ['default', ':focusable']
+          ]);
+          for (const [type, selector] of types) {
+            const jQueryObject = $(selector, element);
+            if (jQueryObject.length > 0 || type === 'default') {
+              return [jQueryObject, type];
+            }
+          }
+        }
+
+        function focusOn() {
+          scope.manualFocus = true;
+          let [jQueryObject, type] = getFocusableInput();
+
+          if (type === 'default') {
+            jQueryObject.first().trigger('focus');
+          }
+
+          else if (type === 'boolean') {
+            // focus on the checked option, if any
+            let checkedItem = $(':checked', element);
+            if (checkedItem.length) {
+              jQueryObject = checkedItem;
+            }
+            jQueryObject.first().trigger('focus');
+          }
+
+          else if (type === 'select2') {
+            // the widget isn't built yet; wait for it to exist
+            waitForElement('.select2-choice', element[0]).then((elements) => {
+              $timeout(() => {
+                const container = elements.first().closest('.select2-container');
+                // this takes care of some non-ajax select2s
+                container.select2('open');
+                // for ajax select2s, wait until the selected option is rendered
+                $(element[0]).one('initSelectionComplete', () => {
+                  container.select2('open');
+                });
+              });
+            });
+          }
+
+          else if (type === 'datepicker') {
+            // the widget isn't built yet; wait for it to exist
+            waitForElement('.hasDatepicker', element[0]).then((elements) => {
+              elements.first().datepicker('show');
+            });
+          }
+        }
+
+        function removeFocus() {
+          getFocusableInput().trigger('blur');
+        }
path: ext/search_kit/ang/crmSearchTasks/crmSearchInput/crmSearchInputFocus.directive.js
user: login: highfalutin
body: @colemanw yes this is just a placeholder, left over from a previous iteration where it was needed. As you said there may be other uses for this directive down the line, and I was thinking this kind of method could be useful again sometime... but that's just hypothetical. I'll take it out for the sake of cleanliness.
created_at: 2025-03-06T19:02:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: civibot[bot]
created_at: 2025-03-04T19:47:37Z
body: 🤖 Thank you for contributing to CiviCRM! ❤️ We will need to test and review this PR. 👷

<details>
<summary><strong>Introduction for new contributors...</strong></summary>

- If this is your first PR, an admin will greenlight automated testing with the command `ok to test` or `add to whitelist`.
- A series of tests will automatically run. You can see the results at the bottom of this page (if there are any problems, it will include a link to see what went wrong).
- A [demo site](http://site-list.test-1.civicrm.org/?filter=core-32291-*) will be built where anyone can try out a version of CiviCRM that includes your changes.
- If this process needs to be repeated, an admin will issue the command `test this please` to rerun tests and build a new demo site.
- Before this PR can be merged, it [needs to be reviewed](https://docs.civicrm.org/dev/en/latest/core/pr-review/). Please keep in mind that reviewers are volunteers, and their response time can vary from a few hours to a few weeks depending on their availability and their knowledge of this particular part of CiviCRM.
- A great way to speed up this process is to "trade reviews" with someone - find an open PR that you feel able to review, and leave a comment like "I'm reviewing this now, could you please review mine?" (include a link to yours). You don't have to wait for a response to get started (and you don't have to stop at one!) the more you review, the faster this process goes for everyone :smile:
- To ensure that you are credited properly in the final [release notes](https://github.com/civicrm/civicrm-core/blob/master/release-notes.md), please add yourself to [contributor-key.yml](https://github.com/civicrm/civicrm-core/blob/master/contributor-key.yml)
- For more information about contributing, see [CONTRIBUTING.md](https://github.com/civicrm/civicrm-core/blob/master/.github/CONTRIBUTING.md).

</details>

<details>
<summary><strong>Quick links for reviewers...</strong></summary>

- [➡️ PR Demo site](http://site-list.test-1.civicrm.org/?filter=core-32291-*) (can be rebuilt with the command `test this please`).
- [📖 Review standards](https://docs.civicrm.org/dev/en/latest/standards/review/)
- [🗒️ Review template](https://civicrm.org/dev/review/template-word.md) (*[brief](https://civicrm.org/dev/review/template-word.md) or [verbose](https://raw.githubusercontent.com/civicrm/civicrm-dev-docs/master/docs/standards/review/template-del-1.0.md)*)

</details>

[➡️ Online demo of this PR 🔗](http://site-list.test-1.civicrm.org/?filter=core-32291-*)

author_association: NONE
user: login: colemanw
created_at: 2025-03-04T20:48:30Z
body: Very cool @highfalutin - I like the idea of making a directive for this. There's probably some other uses for it too.

Yea it's a lot of code but it's all neatly self-contained in the directive, so this looks good to me!
author_association: MEMBER
user: login: highfalutin
created_at: 2025-03-04T23:37:29Z
body: Thanks @colemanw, I just made it slightly more tidy. It could be improved on but I'd be happy to see it merged.
author_association: CONTRIBUTOR
user: login: highfalutin
created_at: 2025-03-06T19:12:45Z
body: @colemanw I made the changes you suggested and removed an unnecessary $scope variable.
author_association: CONTRIBUTOR
user: login: highfalutin
created_at: 2025-03-13T18:00:22Z
body: Testable table is [on the demo site here](http://core-32291-1cj1.test-1.civicrm.org:8003/civicrm/search#/display/In_place_edit_Contacts/editable-table).

To recreate, [add custom fields](http://core-32291-1cj1.test-1.civicrm.org:8003/civicrm/api4#/explorer/CustomField/save?records=%22%5B%5Cn%20%20%20%20%7B%5Cn%5Cn%20%20%20%20%5C%22custom_group_id%5C%22:%201,%5Cn%20%20%20%20%5C%22name%5C%22:%20%5C%22Number_of_pets%5C%22,%5Cn%20%20%20%20%5C%22label%5C%22:%20%5C%22Number%20of%20pets%5C%22,%5Cn%20%20%20%20%5C%22data_type%5C%22:%20%5C%22Int%5C%22,%5Cn%20%20%20%20%5C%22html_type%5C%22:%20%5C%22Text%5C%22,%5Cn%20%20%20%20%5C%22default_value%5C%22:%20null,%5Cn%20%20%20%20%5C%22is_required%5C%22:%20false,%5Cn%20%20%20%20%5C%22is_searchable%5C%22:%20false,%5Cn%20%20%20%20%5C%22is_search_range%5C%22:%20false,%5Cn%20%20%20%20%5C%22weight%5C%22:%205,%5Cn%20%20%20%20%5C%22help_pre%5C%22:%20null,%5Cn%20%20%20%20%5C%22help_post%5C%22:%20null,%5Cn%20%20%20%20%5C%22attributes%5C%22:%20null,%5Cn%20%20%20%20%5C%22is_active%5C%22:%20true,%5Cn%20%20%20%20%5C%22is_view%5C%22:%20false,%5Cn%20%20%20%20%5C%22options_per_line%5C%22:%20null,%5Cn%20%20%20%20%5C%22text_length%5C%22:%20255,%5Cn%20%20%20%20%5C%22start_date_years%5C%22:%20null,%5Cn%20%20%20%20%5C%22end_date_years%5C%22:%20null,%5Cn%20%20%20%20%5C%22date_format%5C%22:%20null,%5Cn%20%20%20%20%5C%22time_format%5C%22:%20null,%5Cn%20%20%20%20%5C%22note_columns%5C%22:%2060,%5Cn%20%20%20%20%5C%22note_rows%5C%22:%204,%5Cn%20%20%20%20%5C%22option_group_id%5C%22:%20null,%5Cn%20%20%20%20%5C%22serialize%5C%22:%200,%5Cn%20%20%20%20%5C%22filter%5C%22:%20null,%5Cn%20%20%20%20%5C%22in_selector%5C%22:%20false,%5Cn%20%20%20%20%5C%22fk_entity%5C%22:%20null,%5Cn%20%20%20%20%5C%22fk_entity_on_delete%5C%22:%20%5C%22set_null%5C%22%5Cn%20%20%20%20%7D,%5Cn%20%20%20%20%7B%5Cn%5Cn%20%20%20%20%5C%22custom_group_id%5C%22:%201,%5Cn%20%20%20%20%5C%22name%5C%22:%20%5C%22Favorite_Soups%5C%22,%5Cn%20%20%20%20%5C%22label%5C%22:%20%5C%22Favorite%20Soups%5C%22,%5Cn%20%20%20%20%5C%22data_type%5C%22:%20%5C%22String%5C%22,%5Cn%20%20%20%20%5C%22html_type%5C%22:%20%5C%22Select%5C%22,%5Cn%20%20%20%20%5C%22default_value%5C%22:%20null,%5Cn%20%20%20%20%5C%22is_required%5C%22:%20false,%5Cn%20%20%20%20%5C%22is_searchable%5C%22:%20false,%5Cn%20%20%20%20%5C%22is_search_range%5C%22:%20false,%5Cn%20%20%20%20%5C%22weight%5C%22:%204,%5Cn%20%20%20%20%5C%22help_pre%5C%22:%20null,%5Cn%20%20%20%20%5C%22help_post%5C%22:%20null,%5Cn%20%20%20%20%5C%22attributes%5C%22:%20null,%5Cn%20%20%20%20%5C%22is_active%5C%22:%20true,%5Cn%20%20%20%20%5C%22is_view%5C%22:%20false,%5Cn%20%20%20%20%5C%22options_per_line%5C%22:%20null,%5Cn%20%20%20%20%5C%22text_length%5C%22:%20255,%5Cn%20%20%20%20%5C%22start_date_years%5C%22:%20null,%5Cn%20%20%20%20%5C%22end_date_years%5C%22:%20null,%5Cn%20%20%20%20%5C%22date_format%5C%22:%20null,%5Cn%20%20%20%20%5C%22time_format%5C%22:%20null,%5Cn%20%20%20%20%5C%22note_columns%5C%22:%2060,%5Cn%20%20%20%20%5C%22note_rows%5C%22:%204,%5Cn%20%20%20%20%5C%22option_group_id%5C%22:%2099,%5Cn%20%20%20%20%5C%22serialize%5C%22:%201,%5Cn%20%20%20%20%5C%22filter%5C%22:%20null,%5Cn%20%20%20%20%5C%22in_selector%5C%22:%20false,%5Cn%20%20%20%20%5C%22fk_entity%5C%22:%20null,%5Cn%20%20%20%20%5C%22fk_entity_on_delete%5C%22:%20%5C%22set_null%5C%22%5Cn%20%20%20%20%7D%5Cn%20%20%20%20%5D%22&_lang=js), then [import search](http://core-32291-1cj1.test-1.civicrm.org:8003/civicrm/admin/search#/list?tab=custom) using attached [search-config.json](https://github.com/user-attachments/files/19234339/search-config.json).
author_association: CONTRIBUTOR