diff --git a/src/app/Library/CrudPanel/Traits/Create.php b/src/app/Library/CrudPanel/Traits/Create.php index a3f5adff29..048a01c74f 100644 --- a/src/app/Library/CrudPanel/Traits/Create.php +++ b/src/app/Library/CrudPanel/Traits/Create.php @@ -3,6 +3,8 @@ namespace Backpack\CRUD\app\Library\CrudPanel\Traits; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; @@ -18,7 +20,7 @@ trait Create * Insert a row in the database. * * @param array $input All input values to be inserted. - * @return \Illuminate\Database\Eloquent\Model + * @return Model */ public function create($input) { @@ -95,7 +97,7 @@ public function getRelationFields($fields = []) /** * Create relations for the provided model. * - * @param \Illuminate\Database\Eloquent\Model $item The current CRUD model. + * @param Model $item The current CRUD model. * @param array $formattedRelations The form data. * @return bool|null */ @@ -131,34 +133,73 @@ private function createRelationsForItem($item, $formattedRelations) case 'BelongsToMany': case 'MorphToMany': $values = $relationDetails['values'][$relationMethod] ?? []; - $values = is_string($values) ? json_decode($values, true) : $values; + $values = is_string($values) ? (json_decode($values, true) ?? []) : $values; + $field = $relationDetails['crudFields'][0] ?? []; - // disabling ConvertEmptyStringsToNull middleware may return null from json_decode() if an empty string is used. - // we need to make sure no null value can go foward so we reassure that values is not null after json_decode() - $values = $values ?? []; + // if the values are multidimensional, we have additional pivot data. + if (is_array($values) && is_multidimensional_array($values)) { + // if the field allow duplicated pivots, we can't use sync or attach from laravel, we need to manually handle the pivot data. + if ($field['allow_duplicate_pivots'] ?? false) { + $keyName = $field['pivot_key_name'] ?? 'id'; + $sentIds = array_filter(array_column($values, $keyName)); + $dbValues = $relation->newPivotQuery()->pluck($keyName)->toArray(); + + $toDelete = array_diff($dbValues, $sentIds); + + if (! empty($toDelete)) { + foreach ($toDelete as $id) { + $relation->newPivot()->where($keyName, $id)->delete(); + } + } + foreach ($values as $value) { + // if it's an existing pivot, update it + $attributes = $this->preparePivotAttributesForSave($value, $relation, $item->getKey(), $keyName); + if (isset($value[$keyName])) { + $relation->newPivot()->where($keyName, $value[$keyName])->update($attributes); + } else { + $relation->newPivot()->create($attributes); + } + } + break; + } - $relationValues = []; + $relationToManyValues = []; - if (is_array($values) && is_multidimensional_array($values)) { foreach ($values as $value) { if (isset($value[$relationMethod])) { - $relationValues[$value[$relationMethod]] = Arr::except($value, $relationMethod); + $relationToManyValues[$value[$relationMethod]] = Arr::except($value, $relationMethod); } } + + $item->{$relationMethod}()->sync($relationToManyValues); + unset($relationToManyValues); + break; } // if there is no relation data, and the values array is single dimensional we have - // an array of keys with no aditional pivot data. sync those. - if (empty($relationValues)) { - $relationValues = array_values($values); + // an array of keys with no additional pivot data. sync those. + if (empty($relationToManyValues)) { + $relationToManyValues = array_values($values); } - - $item->{$relationMethod}()->sync($relationValues); + $item->{$relationMethod}()->sync($relationToManyValues); + unset($relationToManyValues); break; } } } + private function preparePivotAttributesForSave(array $attributes, BelongsToMany|MorphToMany $relation, string|int $relatedItemKey, $pivotKeyName) + { + $attributes[$relation->getForeignPivotKeyName()] = $relatedItemKey; + $attributes[$relation->getRelatedPivotKeyName()] = $attributes[$relation->getRelationName()]; + + if ($relation instanceof MorphToMany) { + $attributes[$relation->getMorphType()] = $relation->getMorphClass(); + } + + return Arr::except($attributes, [$relation->getRelationName(), $pivotKeyName]); + } + /** * Save the attributes of a given HasOne or MorphOne relationship on the * related entry, create or delete it, depending on what was sent in the form. diff --git a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php index 9ae077f110..376c2ed6cf 100644 --- a/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php +++ b/src/app/Library/CrudPanel/Traits/FieldsProtectedMethods.php @@ -313,6 +313,17 @@ protected function makeSureSubfieldsHaveNecessaryAttributes($field) break; } + if ($field['allow_duplicate_pivots'] ?? false) { + $pivotSelectorField['allow_duplicate_pivots'] = true; + $field['subfields'] = Arr::prepend($field['subfields'], [ + 'name' => $field['pivot_key_name'] ?? 'id', + 'type' => 'hidden', + 'wrapper' => [ + 'class' => 'd-none', + ], + ]); + } + $this->setupFieldValidation($pivotSelectorField, $field['name']); $field['subfields'] = Arr::prepend($field['subfields'], $pivotSelectorField); diff --git a/tests/Unit/CrudPanel/CrudPanelCreateTest.php b/tests/Unit/CrudPanel/CrudPanelCreateTest.php index 605e31c126..e7e38be942 100644 --- a/tests/Unit/CrudPanel/CrudPanelCreateTest.php +++ b/tests/Unit/CrudPanel/CrudPanelCreateTest.php @@ -468,6 +468,63 @@ public function testMorphToManyCreatableRelationship() $this->assertEquals('I changed the recommend and the pivot text', $entry->fresh()->recommends->first()->pivot->text); } + public function testMorphToManyCreatableRelationshipWithMultiple() + { + $inputData = $this->getPivotInputData(['recommendsDuplicate' => [ + [ + 'recommendsDuplicate' => 1, + 'text' => 'my pivot recommend field 1', + ], + [ + 'recommendsDuplicate' => 2, + 'text' => 'my pivot recommend field 2', + ], + [ + 'recommendsDuplicate' => 1, + 'text' => 'my pivot recommend field 1x1', + ], + ], + ], true, true); + + $entry = $this->crudPanel->create($inputData); + + $entry = $entry->fresh(); + + $this->assertCount(3, $entry->recommendsDuplicate); + + $this->assertEquals(1, $entry->recommendsDuplicate[0]->id); + $this->assertEquals(1, $entry->recommendsDuplicate[2]->id); + + $inputData['recommendsDuplicate'] = [ + [ + 'recommendsDuplicate' => 1, + 'text' => 'I changed the recommend and the pivot text', + 'id' => 1, + ], + [ + 'recommendsDuplicate' => 2, + 'text' => 'I changed the recommend and the pivot text 2', + 'id' => 2, + ], + [ + 'recommendsDuplicate' => 3, + 'text' => 'new recommend and the pivot text 3', + 'id' => null, + ], + ]; + + $this->crudPanel->update($entry->id, $inputData); + + $entry = $entry->fresh(); + + $this->assertCount(3, $entry->recommendsDuplicate); + $this->assertDatabaseCount('recommendables', 3); + + $this->assertEquals('I changed the recommend and the pivot text', $entry->recommendsDuplicate[0]->pivot->text); + $this->assertEquals('I changed the recommend and the pivot text 2', $entry->recommendsDuplicate[1]->pivot->text); + $this->assertEquals('new recommend and the pivot text 3', $entry->recommendsDuplicate[2]->pivot->text); + } + public function testBelongsToManyWithPivotDataRelationship() { $this->crudPanel->setModel(User::class); @@ -504,12 +561,165 @@ public function testBelongsToManyWithPivotDataRelationship() ]; $entry = $this->crudPanel->create($inputData); - $updateFields = $this->crudPanel->getUpdateFields($entry->id); $this->assertCount(1, $entry->fresh()->superArticles); $this->assertEquals('my first article note', $entry->fresh()->superArticles->first()->pivot->notes); } + public function testBelongsToManyWithMultipleSameRelationIdAndPivotDataRelationship() + { + $inputData = $this->getPivotInputData(['superArticlesDuplicates' => [ + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my first article note', + 'id' => null, + ], + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my second article note', + 'id' => null, + ], + [ + 'superArticlesDuplicates' => 2, + 'notes' => 'my first article2 note', + 'id' => null, + ], + ], + ], true, true); + + $entry = $this->crudPanel->create($inputData); + $relationField = $this->crudPanel->getUpdateFields($entry->id)['superArticlesDuplicates']; + + $this->assertCount(3, $relationField['value']); + + $entry = $entry->fresh(); + + $this->assertCount(3, $entry->superArticlesDuplicates); + $this->assertEquals('my first article note', $entry->superArticles->first()->pivot->notes); + $this->assertEquals('my second article note', $entry->superArticles[1]->pivot->notes); + $this->assertEquals('my first article2 note', $entry->superArticles[2]->pivot->notes); + + $inputData = $this->getPivotInputData(['superArticlesDuplicates' => [ + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my first article note updated', + 'id' => 1, + ], + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my second article note updated', + 'id' => 2, + ], + [ + 'superArticlesDuplicates' => 2, + 'notes' => 'my first article2 note updated', + 'id' => 3, + ], + ], + ], false, true); + + $entry = $this->crudPanel->update($entry->id, $inputData); + $relationField = $this->crudPanel->getUpdateFields($entry->id)['superArticlesDuplicates']; + $this->assertCount(3, $relationField['value']); + + $entry = $entry->fresh(); + + $this->assertCount(3, $entry->superArticlesDuplicates); + $this->assertEquals('my first article note updated', $entry->superArticles[0]->pivot->notes); + $this->assertEquals('my second article note updated', $entry->superArticles[1]->pivot->notes); + $this->assertEquals('my first article2 note updated', $entry->superArticles[2]->pivot->notes); + } + + public function testBelongsToManyAlwaysSaveSinglePivotWhenMultipleNotAllowed() + { + $inputData = $this->getPivotInputData(['superArticlesDuplicates' => [ + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my first article note', + 'id' => null, + ], + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my second article note', + 'id' => null, + ], + [ + 'superArticlesDuplicates' => 2, + 'notes' => 'my first article2 note', + 'id' => null, + ], + ], + ]); + + $entry = $this->crudPanel->create($inputData); + $relationField = $this->crudPanel->getUpdateFields($entry->id)['superArticlesDuplicates']; + + $this->assertCount(2, $relationField['value']); + + $entry = $entry->fresh(); + + $this->assertCount(2, $entry->superArticlesDuplicates); + $this->assertEquals('my second article note', $entry->superArticles[0]->pivot->notes); + $this->assertEquals('my first article2 note', $entry->superArticles[1]->pivot->notes); + } + + public function testBelongsToManyDeletesPivotData() + { + $inputData = $this->getPivotInputData(['superArticlesDuplicates' => [ + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my first article note', + 'id' => null, + ], + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my second article note', + 'id' => null, + ], + [ + 'superArticlesDuplicates' => 2, + 'notes' => 'my first article2 note', + 'id' => null, + ], + ], + ], true, true); + + $entry = $this->crudPanel->create($inputData); + $relationField = $this->crudPanel->getUpdateFields($entry->id)['superArticlesDuplicates']; + + $this->assertCount(3, $relationField['value']); + + $inputData = $this->getPivotInputData(['superArticlesDuplicates' => [ + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'new first article note', + 'id' => null, + ], + [ + 'superArticlesDuplicates' => 1, + 'notes' => 'my second article note updated', + 'id' => 2, + ], + [ + 'superArticlesDuplicates' => 3, + 'notes' => 'my first article2 note updated', + 'id' => 3, + ], + ], + ], false, true); + + $entry = $this->crudPanel->update($entry->id, $inputData); + $relationField = $this->crudPanel->getUpdateFields($entry->id)['superArticlesDuplicates']; + $this->assertCount(3, $relationField['value']); + + $entry = $entry->fresh(); + + $this->assertCount(3, $entry->superArticlesDuplicates); + $this->assertEquals('new first article note', $entry->superArticles[2]->pivot->notes); + $this->assertEquals('my second article note updated', $entry->superArticles[0]->pivot->notes); + $this->assertEquals('my first article2 note updated', $entry->superArticles[1]->pivot->notes); + } + public function testCreateHasOneWithNestedRelationsRepeatableInterface() { $this->crudPanel->setModel(User::class); @@ -1487,4 +1697,45 @@ public function testItThrowsErrorIfDuplicateMorphMapName() ], ]); } + + private function getPivotInputData(array $pivotRelationData, bool $initCrud = true, bool $allowDuplicates = false) + { + $faker = Factory::create(); + + if ($initCrud) { + $this->crudPanel->setModel(User::class); + $this->crudPanel->addFields($this->userInputFieldsNoRelationships); + $this->crudPanel->addField([ + 'name' => array_key_first($pivotRelationData), + 'allow_duplicate_pivots' => $allowDuplicates, + 'pivot_key_name' => 'id', + 'subfields' => [ + [ + 'name' => 'notes', + ], + + ], + ]); + + $article = Article::create([ + 'content' => $faker->text(), + 'tags' => $faker->words(3, true), + 'user_id' => 1, + ]); + $article2 = Article::create([ + 'content' => $faker->text(), + 'tags' => $faker->words(3, true), + 'user_id' => 1, + ]); + } + + $inputData = [ + 'name' => $faker->name, + 'email' => $faker->safeEmail, + 'password' => Hash::make($faker->password()), + 'remember_token' => null, + ]; + + return array_merge($inputData, $pivotRelationData); + } } diff --git a/tests/config/Models/SuperArticlePivot.php b/tests/config/Models/SuperArticlePivot.php new file mode 100644 index 0000000000..72fa3ba7b8 --- /dev/null +++ b/tests/config/Models/SuperArticlePivot.php @@ -0,0 +1,16 @@ +morphToMany('Backpack\CRUD\Tests\config\Models\Recommend', 'recommendable')->withPivot('text'); } + public function recommendsDuplicate() + { + return $this->morphToMany('Backpack\CRUD\Tests\config\Models\Recommend', 'recommendable')->withPivot(['text', 'id']); + } + public function bills() { return $this->morphToMany('Backpack\CRUD\Tests\config\Models\Bill', 'billable'); @@ -67,6 +72,13 @@ public function superArticles() return $this->belongsToMany('Backpack\CRUD\Tests\config\Models\Article', 'articles_user')->withPivot(['notes', 'start_date', 'end_date']); } + public function superArticlesDuplicates() + { + return $this->belongsToMany('Backpack\CRUD\Tests\config\Models\Article', 'articles_user') + ->withPivot(['notes', 'start_date', 'end_date', 'id']) + ->using('Backpack\CRUD\Tests\config\Models\SuperArticlePivot'); + } + public function universes() { return $this->hasMany('Backpack\CRUD\Tests\config\Models\Universe'); diff --git a/tests/config/database/seeds/MorphableSeeders.php b/tests/config/database/seeds/MorphableSeeders.php index ddff894df9..6831f54b2c 100644 --- a/tests/config/database/seeds/MorphableSeeders.php +++ b/tests/config/database/seeds/MorphableSeeders.php @@ -25,6 +25,10 @@ public function run() 'title' => $faker->title, 'created_at' => $now, 'updated_at' => $now, + ], [ + 'title' => $faker->title, + 'created_at' => $now, + 'updated_at' => $now, ]]); DB::table('bills')->insert([[