From 35ceb7e68991139f05cee8cd283afdb2a8ed12d7 Mon Sep 17 00:00:00 2001
From: Coleman Watts <coleman@civicrm.org>
Date: Tue, 4 Jan 2022 21:25:34 -0500
Subject: [PATCH] SearchKit - Allow creation of new records via in-place edit

Allows e.g. an email record to be created if one does not already exist.
Fixes dev/core#2853
---
 Civi/Api4/Query/Api4SelectQuery.php           |  3 +-
 .../SearchDisplay/AbstractRunAction.php       | 61 ++++++++++++++-----
 .../crmSearchDisplayEditable.component.js     |  2 +-
 3 files changed, 50 insertions(+), 16 deletions(-)

diff --git a/Civi/Api4/Query/Api4SelectQuery.php b/Civi/Api4/Query/Api4SelectQuery.php
index a6e0eaed088b..76a3842ee7da 100644
--- a/Civi/Api4/Query/Api4SelectQuery.php
+++ b/Civi/Api4/Query/Api4SelectQuery.php
@@ -751,6 +751,7 @@ private function addExplicitJoins() {
         'alias' => $alias,
         'table' => $tableName,
         'bridge' => NULL,
+        'on' => array_filter(array_filter($join, 'is_array')),
       ];
       // If the first condition is a string, it's the name of a bridge entity
       if (!empty($join[0]) && is_string($join[0]) && \CRM_Utils_Rule::alphanumeric($join[0])) {
@@ -758,7 +759,7 @@ private function addExplicitJoins() {
       }
       else {
         $conditions = $this->getJoinConditions($join, $entity, $alias, $joinEntityFields);
-        foreach (array_filter($join) as $clause) {
+        foreach ($join['on'] as $clause) {
           $conditions[] = $this->treeWalkClauses($clause, 'ON');
         }
         $this->join($side, $tableName, $alias, $conditions);
diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
index 4d95db8520d3..74dbc046b646 100644
--- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
+++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php
@@ -488,35 +488,67 @@ private function getUrl(string $path, $query = NULL) {
   /**
    * @param $column
    * @param $data
-   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, fk_entity: string, value_key: string, record: array, value: mixed}|null
+   * @return array{entity: string, action: string, input_type: string, data_type: string, options: bool, serialize: bool, nullable: bool, fk_entity: string, value_key: string, record: array, value: mixed}|null
    */
   private function formatEditableColumn($column, $data) {
     $editable = $this->getEditableInfo($column['key']);
+    $editable['record'] = [];
+    // Generate params to edit existing record
     if (!empty($data[$editable['id_path']])) {
+      $editable['action'] = 'update';
+      $editable['record'][$editable['id_key']] = $data[$editable['id_path']];
+      $editable['value'] = $data[$editable['value_path']];
+    }
+    // Generate params to create new record, if applicable
+    elseif ($editable['explicit_join']) {
+      $editable['action'] = 'create';
+      $editable['value'] = NULL;
+      $editable['nullable'] = FALSE;
+      // Get values for creation from the join clause
+      $join = $this->getQuery()->getExplicitJoin($editable['explicit_join']);
+      foreach ($join['on'] ?? [] as $clause) {
+        if (is_array($clause) && count($clause) === 3 && $clause[1] === '=') {
+          // Because clauses are reversible, check both directions to see which side has a fieldName belonging to this join
+          foreach ([0 => 2, 2 => 0] as $field => $value) {
+            if (strpos($clause[$field], $editable['explicit_join'] . '.') === 0) {
+              $fieldName = substr($clause[$field], strlen($editable['explicit_join']) + 1);
+              // If the value is a field, get it from the data
+              if (isset($data[$clause[$value]])) {
+                $editable['record'][$fieldName] = $data[$clause[$value]];
+              }
+              // If it's a literal bool or number
+              elseif (is_bool($clause[$value]) || is_numeric($clause[$value])) {
+                $editable['record'][$fieldName] = $clause[$value];
+              }
+              // If it's a literal string it will be quoted
+              elseif (is_string($clause[$value]) && in_array($clause[$value][0], ['"', "'"], TRUE) && substr($clause[$value], -1) === $clause[$value][0]) {
+                $editable['record'][$fieldName] = substr($clause[$value], 1, -1);
+              }
+            }
+          }
+        }
+      }
+    }
+    // Ensure current user has access
+    if ($editable['record']) {
       $access = civicrm_api4($editable['entity'], 'checkAccess', [
-        'action' => 'update',
-        'values' => [
-          $editable['id_key'] => $data[$editable['id_path']],
-        ],
+        'action' => $editable['action'],
+        'values' => $editable['record'],
       ], 0)['access'];
-      if (!$access) {
-        return NULL;
+      if ($access) {
+        \CRM_Utils_Array::remove($editable, 'id_key', 'id_path', 'value_path', 'explicit_join');
+        return $editable;
       }
-      $editable['record'] = [
-        $editable['id_key'] => $data[$editable['id_path']],
-      ];
-      $editable['value'] = $data[$editable['value_path']];
-      \CRM_Utils_Array::remove($editable, 'id_key', 'id_path', 'value_path');
-      return $editable;
     }
     return NULL;
   }
 
   /**
    * @param $key
-   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, nullable: bool, fk_entity: string, value_key: string, value_path: string, id_key: string, id_path: string}|null
+   * @return array{entity: string, input_type: string, data_type: string, options: bool, serialize: bool, nullable: bool, fk_entity: string, value_key: string, value_path: string, id_key: string, id_path: string, explicit_join: string}|null
    */
   private function getEditableInfo($key) {
+    // Strip pseudoconstant suffix
     [$key] = explode(':', $key);
     $field = $this->getField($key);
     // If field is an implicit join to another entity (not a custom group), use the original fk field
@@ -543,6 +575,7 @@ private function getEditableInfo($key) {
         'value_path' => $key,
         'id_key' => $idKey,
         'id_path' => $idPath,
+        'explicit_join' => $field['explicit_join'],
       ];
     }
     return NULL;
diff --git a/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js b/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
index f205e4a3aa05..cedb4db87d53 100644
--- a/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
+++ b/ext/search_kit/ang/crmSearchDisplay/crmSearchDisplayEditable.component.js
@@ -57,7 +57,7 @@
         var record = _.cloneDeep(col.edit.record);
         record[col.edit.value_key] = ctrl.value;
         $('input', $element).attr('disabled', true);
-        ctrl.doSave({apiCall: [col.edit.entity, 'update', {values: record}]});
+        ctrl.doSave({apiCall: [col.edit.entity, col.edit.action, {values: record}]});
       };
 
       function loadOptions() {