diff --git a/Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php b/Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php index 4bff31bf69ca..e42248279a48 100644 --- a/Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php +++ b/Civi/Api4/Service/Schema/Joinable/CustomGroupJoinable.php @@ -13,6 +13,7 @@ namespace Civi\Api4\Service\Schema\Joinable; use Civi\Api4\CustomField; +use Civi\Api4\Utils\CoreUtil; class CustomGroupJoinable extends Joinable { @@ -52,12 +53,13 @@ public function getEntityFields() { $cacheKey = 'APIv4_CustomGroupJoinable-' . $this->getTargetTable(); $entityFields = (array) \Civi::cache('metadata')->get($cacheKey); if (!$entityFields) { + $baseEntity = CoreUtil::getApiNameFromTableName($this->getBaseTable()); $fields = CustomField::get(FALSE) ->setSelect(['custom_group_id.name', 'custom_group_id.extends', 'custom_group_id.table_name', 'custom_group_id.title', '*']) ->addWhere('custom_group_id.table_name', '=', $this->getTargetTable()) ->execute(); foreach ($fields as $field) { - $entityFields[] = \Civi\Api4\Service\Spec\SpecFormatter::arrayToField($field, self::getEntityFromExtends($field['custom_group_id.extends'])); + $entityFields[] = \Civi\Api4\Service\Spec\SpecFormatter::arrayToField($field, $baseEntity); } \Civi::cache('metadata')->set($cacheKey, $entityFields); } diff --git a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php index 5928ffc19038..1b592602a787 100644 --- a/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php +++ b/ext/search_kit/Civi/Api4/Action/SearchDisplay/AbstractRunAction.php @@ -549,7 +549,7 @@ private function formatEditableColumn($column, $data) { $editable['value'] = $data[$editable['value_path']]; } // Generate params to create new record, if applicable - elseif ($editable['explicit_join']) { + elseif ($editable['explicit_join'] && !$this->getJoin($editable['explicit_join'])['bridge']) { $editable['action'] = 'create'; $editable['value'] = NULL; $editable['nullable'] = FALSE; @@ -577,6 +577,21 @@ private function formatEditableColumn($column, $data) { } } } + // Ensure all required values exist for create action + $vals = array_keys(array_filter($editable['record'])); + $vals[] = $editable['value_key']; + $missingRequiredFields = civicrm_api4($editable['entity'], 'getFields', [ + 'action' => 'create', + 'where' => [ + ['type', '=', 'Field'], + ['required', '=', TRUE], + ['default_value', 'IS NULL'], + ['name', 'NOT IN', $vals], + ], + ]); + if ($missingRequiredFields->count() || count($vals) === 1) { + return NULL; + } } // Ensure current user has access if ($editable['record']) { diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php index 7a2b6e0386aa..f5d8deb1b71d 100644 --- a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunTest.php @@ -6,6 +6,7 @@ use Civi\Api4\Contact; use Civi\Api4\ContactType; use Civi\Api4\Email; +use Civi\Api4\Phone; use Civi\Api4\SavedSearch; use Civi\Api4\SearchDisplay; use Civi\Api4\UFMatch; @@ -309,6 +310,144 @@ public function testRunWithSmartyRewrite() { } } + /** + * Test in-place editable for update and create. + */ + public function testInPlaceEditAndCreate() { + $lastName = uniqid(__FUNCTION__); + $sampleData = [ + ['first_name' => 'One', 'last_name' => $lastName], + ['last_name' => $lastName], + ]; + $contacts = Contact::save(FALSE)->setRecords($sampleData)->execute()->column('id'); + $email = Email::create(FALSE) + ->addValue('contact_id', $contacts[0]) + ->addValue('email', 'testmail@unit.test') + ->execute()->single()['id']; + $phone = Phone::create(FALSE) + ->addValue('contact_id', $contacts[1]) + ->addValue('phone', '123456') + ->execute()->single()['id']; + + $params = [ + 'checkPermissions' => FALSE, + 'return' => 'page:1', + 'savedSearch' => [ + 'api_entity' => 'Contact', + 'api_params' => [ + 'version' => 4, + 'select' => ['first_name', 'Contact_Email_contact_id_01.email', 'Contact_Phone_contact_id_01.phone'], + 'where' => [['last_name', '=', $lastName]], + 'join' => [ + [ + "Email AS Contact_Email_contact_id_01", + "LEFT", + ["id", "=", "Contact_Email_contact_id_01.contact_id"], + ["Contact_Email_contact_id_01.is_primary", "=", TRUE], + ], + [ + "Phone AS Contact_Phone_contact_id_01", + "LEFT", + ["id", "=", "Contact_Phone_contact_id_01.contact_id"], + ], + ], + ], + ], + 'display' => [ + 'type' => 'table', + 'label' => '', + 'settings' => [ + 'limit' => 20, + 'pager' => FALSE, + 'columns' => [ + [ + 'key' => 'first_name', + 'label' => 'Name', + 'type' => 'field', + 'editable' => TRUE, + ], + [ + 'key' => 'Contact_Email_contact_id_01.email', + 'label' => 'Email', + 'type' => 'field', + 'editable' => TRUE, + ], + [ + 'key' => 'Contact_Phone_contact_id_01.phone', + 'label' => 'Phone', + 'type' => 'field', + 'editable' => TRUE, + ], + ], + 'sort' => [ + ['id', 'ASC'], + ], + ], + ], + ]; + $result = civicrm_api4('SearchDisplay', 'run', $params); + + // Contact 1 first name can be updated + $this->assertEquals('One', $result[0]['columns'][0]['val']); + $this->assertEquals($contacts[0], $result[0]['columns'][0]['edit']['record']['id']); + $this->assertEquals('Contact', $result[0]['columns'][0]['edit']['entity']); + $this->assertEquals('Text', $result[0]['columns'][0]['edit']['input_type']); + $this->assertEquals('String', $result[0]['columns'][0]['edit']['data_type']); + $this->assertEquals('first_name', $result[0]['columns'][0]['edit']['value_key']); + $this->assertEquals('update', $result[0]['columns'][0]['edit']['action']); + $this->assertEquals('One', $result[0]['columns'][0]['edit']['value']); + + // Contact 1 email can be updated + $this->assertEquals('testmail@unit.test', $result[0]['columns'][1]['val']); + $this->assertEquals($email, $result[0]['columns'][1]['edit']['record']['id']); + $this->assertEquals('Email', $result[0]['columns'][1]['edit']['entity']); + $this->assertEquals('Text', $result[0]['columns'][1]['edit']['input_type']); + $this->assertEquals('String', $result[0]['columns'][1]['edit']['data_type']); + $this->assertEquals('email', $result[0]['columns'][1]['edit']['value_key']); + $this->assertEquals('update', $result[0]['columns'][1]['edit']['action']); + $this->assertEquals('testmail@unit.test', $result[0]['columns'][1]['edit']['value']); + + // Contact 1 - new phone can be created + $this->assertNull($result[0]['columns'][2]['val']); + $this->assertEquals(['contact_id' => $contacts[0]], $result[0]['columns'][2]['edit']['record']); + $this->assertEquals('Phone', $result[0]['columns'][2]['edit']['entity']); + $this->assertEquals('Text', $result[0]['columns'][2]['edit']['input_type']); + $this->assertEquals('String', $result[0]['columns'][2]['edit']['data_type']); + $this->assertEquals('phone', $result[0]['columns'][2]['edit']['value_key']); + $this->assertEquals('create', $result[0]['columns'][2]['edit']['action']); + $this->assertNull($result[0]['columns'][2]['edit']['value']); + + // Contact 2 first name can be added + $this->assertNull($result[1]['columns'][0]['val']); + $this->assertEquals($contacts[1], $result[1]['columns'][0]['edit']['record']['id']); + $this->assertEquals('Contact', $result[1]['columns'][0]['edit']['entity']); + $this->assertEquals('Text', $result[1]['columns'][0]['edit']['input_type']); + $this->assertEquals('String', $result[1]['columns'][0]['edit']['data_type']); + $this->assertEquals('first_name', $result[1]['columns'][0]['edit']['value_key']); + $this->assertEquals('update', $result[1]['columns'][0]['edit']['action']); + $this->assertNull($result[1]['columns'][0]['edit']['value']); + + // Contact 2 - new email can be created + $this->assertNull($result[1]['columns'][1]['val']); + $this->assertEquals(['contact_id' => $contacts[1], 'is_primary' => TRUE], $result[1]['columns'][1]['edit']['record']); + $this->assertEquals('Email', $result[1]['columns'][1]['edit']['entity']); + $this->assertEquals('Text', $result[1]['columns'][1]['edit']['input_type']); + $this->assertEquals('String', $result[1]['columns'][1]['edit']['data_type']); + $this->assertEquals('email', $result[1]['columns'][1]['edit']['value_key']); + $this->assertEquals('create', $result[1]['columns'][1]['edit']['action']); + $this->assertNull($result[1]['columns'][1]['edit']['value']); + + // Contact 2 phone can be updated + $this->assertEquals('123456', $result[1]['columns'][2]['val']); + $this->assertEquals($phone, $result[1]['columns'][2]['edit']['record']['id']); + $this->assertEquals('Phone', $result[1]['columns'][2]['edit']['entity']); + $this->assertEquals('Text', $result[1]['columns'][2]['edit']['input_type']); + $this->assertEquals('String', $result[1]['columns'][2]['edit']['data_type']); + $this->assertEquals('phone', $result[1]['columns'][2]['edit']['value_key']); + $this->assertEquals('update', $result[1]['columns'][2]['edit']['action']); + $this->assertEquals('123456', $result[1]['columns'][2]['edit']['value']); + } + /** * Test running a searchDisplay as a restricted user. */ diff --git a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunWithCustomFieldTest.php b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunWithCustomFieldTest.php index 9900a679268c..d4d51d1b4367 100644 --- a/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunWithCustomFieldTest.php +++ b/ext/search_kit/tests/phpunit/api/v4/SearchDisplay/SearchRunWithCustomFieldTest.php @@ -1,16 +1,18 @@ addWhere('id', '>', 0)->execute(); + // Core bug: `civicrm_entity_file` doesn't get cleaned up when a contact is deleted, + // so trying to delete `civicrm_file` causes a constraint violation :( + // For now, truncate the table manually. + $this->cleanup([ + 'tablesToTruncate' => [ + 'civicrm_entity_file', + ], + ]); + // Now the file record can be auto-deleted parent::tearDown(); } @@ -35,8 +45,7 @@ public function testRunWithImageField() { CustomGroup::create(FALSE) ->addValue('title', 'TestSearchFields') ->addValue('extends', 'Individual') - ->execute() - ->first(); + ->execute(); CustomField::create(FALSE) ->addValue('label', 'MyFile') @@ -47,16 +56,16 @@ public function testRunWithImageField() { $lastName = uniqid(__FUNCTION__); - $file = File::create() - ->addValue('mime_type', 'image/png') - ->addValue('uri', "tmp/$lastName.png") - ->execute()->first(); + $file = $this->createTestRecord('File', [ + 'mime_type' => 'image/png', + 'uri' => "tmp/$lastName.png", + ]); $sampleData = [ ['first_name' => 'Zero', 'last_name' => $lastName, 'TestSearchFields.MyFile' => $file['id']], ['first_name' => 'One', 'middle_name' => 'None', 'last_name' => $lastName], ]; - Contact::save(FALSE)->setRecords($sampleData)->execute(); + $this->saveTestRecords('Contact', ['records' => $sampleData]); $params = [ 'checkPermissions' => FALSE, @@ -103,4 +112,161 @@ public function testRunWithImageField() { $this->assertStringContainsString('example.com', $result[1]['columns'][1]['img']['src']); } + public function testEditableRelationshipCustomFields() { + + CustomGroup::create(FALSE) + ->addValue('title', 'TestChildFields') + ->addValue('extends', 'Relationship') + ->addValue('extends_entity_column_value:name', ['Child of']) + ->addChain('fields', CustomField::create() + ->addValue('custom_group_id', '$id') + ->addValue('label', 'Child') + ->addValue('html_type', 'Text') + ) + ->execute(); + + CustomGroup::create(FALSE) + ->addValue('title', 'TestSpouseFields') + ->addValue('extends', 'Relationship') + ->addValue('extends_entity_column_value:name', ['Spouse of']) + ->addChain('fields', CustomField::create() + ->addValue('custom_group_id', '$id') + ->addValue('label', 'Spouse') + ->addValue('html_type', 'Text') + ) + ->execute(); + + $contacts = $this->saveTestRecords('Contact', [ + 'records' => array_fill(0, 3, []), + ]); + // Reverse the contacts array so we don't get an accidental match of + // sequential contact ids and relationship ids. This test needs to ensure + // that the correct id is always being used. + $contacts = array_column(array_reverse((array) $contacts), 'id'); + + $spouse = $this->createTestRecord('Contact', ['first_name' => 's'])['id']; + $child = $this->createTestRecord('Contact', ['first_name' => 'c'])['id']; + + $childRel = $this->createTestRecord('Relationship', [ + 'contact_id_b' => $contacts[0], + 'contact_id_a' => $child, + 'relationship_type_id:name' => 'Child of', + 'TestChildFields.Child' => 'abc', + ])['id']; + $spouseRel = $this->createTestRecord('Relationship', [ + 'contact_id_a' => $contacts[1], + 'contact_id_b' => $spouse, + 'relationship_type_id:name' => 'Spouse of', + 'description' => 'Married', + ])['id']; + + $params = [ + 'checkPermissions' => FALSE, + 'return' => 'page:1', + 'savedSearch' => [ + 'api_entity' => 'Contact', + 'api_params' => [ + 'version' => 4, + 'select' => [ + 'first_name', + 'Relative.first_name', + 'Relative.description', + 'Relative.TestChildFields.Child', + 'Relative.TestSpouseFields.Spouse', + ], + 'where' => [['id', 'IN', $contacts]], + 'join' => [ + [ + 'Contact AS Relative', + 'LEFT', + 'RelationshipCache', + ['id', '=', 'Relative.far_contact_id'], + ], + ], + ], + ], + 'display' => [ + 'type' => 'table', + 'label' => '', + 'settings' => [ + 'limit' => 20, + 'pager' => FALSE, + 'columns' => [ + [ + 'key' => 'first_name', + 'label' => 'Name', + 'type' => 'field', + 'editable' => TRUE, + ], + [ + 'key' => 'Relative.first_name', + 'label' => 'Child Name', + 'type' => 'field', + 'editable' => TRUE, + ], + [ + 'key' => 'Relative.description', + 'label' => 'Relationship Description', + 'type' => 'field', + 'editable' => TRUE, + ], + [ + 'key' => 'Relative.TestChildFields.Child', + 'label' => 'Child Custom', + 'type' => 'field', + 'editable' => TRUE, + ], + [ + 'key' => 'Relative.TestSpouseFields.Spouse', + 'label' => 'Spouse Custom', + 'type' => 'field', + 'editable' => TRUE, + ], + ], + 'sort' => [ + // To match the reversed array of contacts + ['id', 'DESC'], + ], + ], + ], + ]; + + $result = civicrm_api4('SearchDisplay', 'run', $params); + $this->assertCount(3, $result); + // Editing first name - edit id should always match primary contact id + $this->assertEquals($contacts[0], $result[0]['columns'][0]['edit']['record']['id']); + $this->assertEquals($contacts[1], $result[1]['columns'][0]['edit']['record']['id']); + $this->assertEquals($contacts[2], $result[2]['columns'][0]['edit']['record']['id']); + + // First contact has a child relation but not a spouse + $this->assertEquals('c', $result[0]['columns'][1]['val']); + $this->assertEquals($child, $result[0]['columns'][1]['edit']['record']['id']); + $this->assertEquals('', $result[0]['columns'][2]['val']); + $this->assertEquals($childRel, $result[0]['columns'][2]['edit']['record']['id']); + $this->assertEquals('abc', $result[0]['columns'][3]['val']); + $this->assertEquals($childRel, $result[0]['columns'][3]['edit']['record']['id']); + $this->assertNull($result[0]['columns'][4]['val']); + // $this->assertArrayNotHasKey('edit', $result[0]['columns'][4]); + + // Second contact has a spouse relation but not a child + $this->assertEquals('s', $result[1]['columns'][1]['val']); + $this->assertEquals($spouse, $result[1]['columns'][1]['edit']['record']['id']); + $this->assertEquals('Married', $result[1]['columns'][2]['val']); + $this->assertEquals($spouseRel, $result[1]['columns'][2]['edit']['record']['id']); + $this->assertNull($result[1]['columns'][3]['val']); + // $this->assertArrayNotHasKey('edit', $result[1]['columns'][3]); + $this->assertNull($result[1]['columns'][4]['val']); + $this->assertEquals($spouseRel, $result[1]['columns'][4]['edit']['record']['id']); + + // Third contact is all alone in this world... + $this->assertNull($result[2]['columns'][1]['val']); + $this->assertArrayNotHasKey('edit', $result[2]['columns'][1]); + $this->assertNull($result[2]['columns'][2]['val']); + $this->assertArrayNotHasKey('edit', $result[2]['columns'][2]); + $this->assertNull($result[2]['columns'][3]['val']); + $this->assertArrayNotHasKey('edit', $result[2]['columns'][3]); + $this->assertNull($result[2]['columns'][4]['val']); + $this->assertArrayNotHasKey('edit', $result[2]['columns'][4]); + } + } diff --git a/tests/phpunit/api/v4/Api4TestBase.php b/tests/phpunit/api/v4/Api4TestBase.php index ff0c5282dc5c..28afdf581e12 100644 --- a/tests/phpunit/api/v4/Api4TestBase.php +++ b/tests/phpunit/api/v4/Api4TestBase.php @@ -61,7 +61,16 @@ public function tearDown(): void { if (!in_array('Civi\Test\TransactionalInterface', $impliments, TRUE)) { // Delete all test records in reverse order to prevent fk constraints foreach (array_reverse($this->testRecords) as $record) { - civicrm_api4($record[0], 'delete', ['checkPermissions' => FALSE, 'where' => $record[1]]); + $params = ['checkPermissions' => FALSE, 'where' => $record[1]]; + + // Set useTrash param if it exists + $entityClass = CoreUtil::getApiClass($record[0]); + $deleteAction = $entityClass::delete(); + if (property_exists($deleteAction, 'useTrash')) { + $params['useTrash'] = FALSE; + } + + civicrm_api4($record[0], 'delete', $params); } } }