From 93ec46e9dc9f2d4334fd386fdefaa8505f56d08e Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 11:14:11 -0400
Subject: [PATCH 01/23] feat: add Concept model unit test
---
tests/Unit/.gitkeep | 0
tests/Unit/Models/ConceptTest.php | 9 +++++++++
2 files changed, 9 insertions(+)
delete mode 100644 tests/Unit/.gitkeep
create mode 100644 tests/Unit/Models/ConceptTest.php
diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/Unit/Models/ConceptTest.php b/tests/Unit/Models/ConceptTest.php
new file mode 100644
index 0000000..dfc25c3
--- /dev/null
+++ b/tests/Unit/Models/ConceptTest.php
@@ -0,0 +1,9 @@
+
Date: Mon, 28 Oct 2024 11:16:11 -0400
Subject: [PATCH 02/23] feat: Add unit tests for Concept model relationships
---
tests/Unit/Models/ConceptTest.php | 38 ++++++++++++++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/tests/Unit/Models/ConceptTest.php b/tests/Unit/Models/ConceptTest.php
index dfc25c3..e691099 100644
--- a/tests/Unit/Models/ConceptTest.php
+++ b/tests/Unit/Models/ConceptTest.php
@@ -2,8 +2,44 @@
namespace Tests\Unit\Models;
-use PHPUnit\Framework\TestCase;
+use Tests\TestCase;
+use App\Models\Concept;
+use Illuminate\Foundation\Testing\RefreshDatabase;
class ConceptTest extends TestCase
{
+ use RefreshDatabase;
+
+ public function test_concept_can_have_broader_relationships()
+ {
+ $concept = Concept::factory()->create();
+ $broaderConcept = Concept::factory()->create();
+
+ $concept->addBroader($broaderConcept->id);
+
+ $this->assertTrue($concept->broader->contains($broaderConcept));
+ $this->assertTrue($broaderConcept->narrower->contains($concept));
+ }
+
+ public function test_concept_can_have_narrower_relationships()
+ {
+ $concept = Concept::factory()->create();
+ $narrowerConcept = Concept::factory()->create();
+
+ $concept->addNarrower($narrowerConcept->id);
+
+ $this->assertTrue($concept->narrower->contains($narrowerConcept));
+ $this->assertTrue($narrowerConcept->broader->contains($concept));
+ }
+
+ public function test_concept_can_have_related_relationships()
+ {
+ $concept = Concept::factory()->create();
+ $relatedConcept = Concept::factory()->create();
+
+ $concept->addRelated($relatedConcept->id);
+
+ $this->assertTrue($concept->related->contains($relatedConcept));
+ $this->assertTrue($relatedConcept->related->contains($concept));
+ }
}
From aa8e2f6ab5103780ed7e045134991e1f60e8d588 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 12:13:29 -0400
Subject: [PATCH 03/23] feat: fix concept relationships
---
app/Models/Concept.php | 25 +++++++++----------------
tests/Unit/Models/ConceptTest.php | 4 ++--
2 files changed, 11 insertions(+), 18 deletions(-)
diff --git a/app/Models/Concept.php b/app/Models/Concept.php
index 1b6457b..cda2871 100644
--- a/app/Models/Concept.php
+++ b/app/Models/Concept.php
@@ -52,13 +52,6 @@ public function broader()
->wherePivot("relationship_type", "broader");
}
- // This version requires insert of the two relationships (both ways, narrower and broader)
- //public function narrower() {
- //return $this->belongsToMany("App\Models\Concept", "concept_relationships", "concept_id", "related_concept_id")
- //->withPivot("relationship_type")
- //->wherePivot("relationship_type", "narrower");
- //}
-
// This version requires insert only one relationships, the other relationship is inferred from concept_id or related_concept_id
// coming from other direction
public function narrower()
@@ -70,7 +63,7 @@ public function narrower()
public function related()
{
- return $this->belongsToMany("App\Models\Concept", "concept_relationships", "related_concept_id", "concept_id")
+ return $this->belongsToMany("App\Models\Concept", "concept_relationships", "concept_id", "related_concept_id")
->withPivot("relationship_type")
->wherePivot("relationship_type", "related");
}
@@ -82,18 +75,18 @@ public function addBroader($conceptId)
public function addRelated($conceptId)
{
- return $this->related()->attach([$conceptId => ["relationship_type" => "related"]]);
- }
+ // Add both directions of the relationship
+ $this->belongsToMany("App\Models\Concept", "concept_relationships", "concept_id", "related_concept_id")
+ ->attach([$conceptId => ["relationship_type" => "related"]]);
+
+ $this->belongsToMany("App\Models\Concept", "concept_relationships", "related_concept_id", "concept_id")
+ ->attach([$conceptId => ["relationship_type" => "related"]]);
- // This version requires insert of the two relationships (both ways, narrower and broader)
- // public function addNarrower($concept) {
- // // TODO : check it works for new narrower enum
- // return $this->narrower()->attach([$concept => ["relationship_type" => "narrower"]]);
- // }
+ return true;
+ }
public function addNarrower($conceptId)
{
- // TODO : check it works for new narrower enum
return $this->narrower()->attach([$conceptId => ["relationship_type" => "broader"]]);
}
diff --git a/tests/Unit/Models/ConceptTest.php b/tests/Unit/Models/ConceptTest.php
index e691099..54ab096 100644
--- a/tests/Unit/Models/ConceptTest.php
+++ b/tests/Unit/Models/ConceptTest.php
@@ -4,11 +4,11 @@
use Tests\TestCase;
use App\Models\Concept;
-use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Foundation\Testing\DatabaseTransactions;
class ConceptTest extends TestCase
{
- use RefreshDatabase;
+ use DatabaseTransactions;
public function test_concept_can_have_broader_relationships()
{
From df564b0f1cdc259a80b6b9aa5dee010dad000fd1 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 12:23:21 -0400
Subject: [PATCH 04/23] feat: add new alternate terms to concept
---
tests/Feature/API/ConceptsTest.php | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php
index dc10ed3..4045507 100644
--- a/tests/Feature/API/ConceptsTest.php
+++ b/tests/Feature/API/ConceptsTest.php
@@ -56,7 +56,7 @@ public function test_authorized_user_can_create_concept(): void
'alternate_terms' => [
'term1',
'term2',
- 'term3'
+ 'term3',
],
]);
@@ -74,12 +74,12 @@ public function test_authorized_user_can_update_concept(): void
$concept = Concept::factory()->create();
$conceptCategories = Vocabulary::where('type', 'concept_category')->get()->random(2)->toArray();
$response = $this->patchJson("/api/concepts/{$concept->id}", [
- 'conceptCategories' => $conceptCategories
+ 'conceptCategories' => $conceptCategories,
]);
$updatedCategories = Concept::find($concept->id)->conceptCategories->toArray();
$keysToRemove = ["pivot"];
- $cleanedCategories = array_map(function($item) use ($keysToRemove) {
+ $cleanedCategories = array_map(function ($item) use ($keysToRemove) {
return array_diff_key($item, array_flip($keysToRemove));
}, $updatedCategories);
$this->assertEqualsCanonicalizing($conceptCategories, $cleanedCategories);
@@ -99,7 +99,7 @@ public function test_authorized_user_can_update_concept_relationships(): void
$relatedConcept = Concept::factory()->create();
$response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [
'relation_type' => 'broader',
- 'related_id' => $relatedConcept,
+ 'related_id' => $relatedConcept->id,
]);
$response->assertStatus(200);
From f93c79db5a35304e3b2c26f3876621779d36b897 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 12:23:22 -0400
Subject: [PATCH 05/23] feat: Add tests for broader, narrower, and related
concept relationships
---
tests/Feature/API/ConceptsTest.php | 22 +++++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php
index 4045507..20c906e 100644
--- a/tests/Feature/API/ConceptsTest.php
+++ b/tests/Feature/API/ConceptsTest.php
@@ -96,13 +96,33 @@ public function test_authorized_user_can_update_concept_relationships(): void
Sanctum::actingAs($user);
$concept = Concept::factory()->create();
+ $broaderConcept = Concept::factory()->create();
+ $narrowerConcept = Concept::factory()->create();
$relatedConcept = Concept::factory()->create();
+
+ // Test broader relationship
$response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [
'relation_type' => 'broader',
- 'related_id' => $relatedConcept->id,
+ 'related_id' => $broaderConcept->id,
+ ]);
+ $response->assertStatus(200);
+ $this->assertTrue($concept->broader->contains($broaderConcept));
+
+ // Test narrower relationship
+ $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'narrower',
+ 'related_id' => $narrowerConcept->id,
]);
+ $response->assertStatus(200);
+ $this->assertTrue($concept->narrower->contains($narrowerConcept));
+ // Test related relationship
+ $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'related',
+ 'related_id' => $relatedConcept->id,
+ ]);
$response->assertStatus(200);
+ $this->assertTrue($concept->related->contains($relatedConcept));
}
public function test_authorized_user_can_deprecate_concept(): void
From 21d02753405d2fa73751f211428eecab438b1768 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 12:45:15 -0400
Subject: [PATCH 06/23] fix: correct unauthorized user relationship updating to
pass related_id instead of whole model
---
tests/Feature/API/ConceptsTest.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php
index 20c906e..dcf4d03 100644
--- a/tests/Feature/API/ConceptsTest.php
+++ b/tests/Feature/API/ConceptsTest.php
@@ -197,7 +197,7 @@ public function test_unauthorized_user_cannot_update_concept_relationships(): vo
$relatedConcept = Concept::factory()->create();
$response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [
'relation_type' => 'broader',
- 'related_id' => $relatedConcept,
+ 'related_id' => $relatedConcept->id,
]);
$response->assertStatus(403);
From d7cdd90d0d000fadcbbd97849b6409da384813c7 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 12:46:10 -0400
Subject: [PATCH 07/23] chore: phpfmt updates
---
app/Http/Controllers/API/ConceptController.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php
index 2a0933b..fe60b21 100644
--- a/app/Http/Controllers/API/ConceptController.php
+++ b/app/Http/Controllers/API/ConceptController.php
@@ -174,7 +174,7 @@ public function relateConcepts(Request $request, Concept $concept)
if ($request->user()->cannot('update', $concept)) {
abort(403);
}
-
+
$relation_type = $request->input('relation_type');
$related_id = $request->input('related_id');
@@ -205,7 +205,7 @@ public function deprecate(Request $request, Concept $concept)
if ($request->user()->cannot('update', $concept)) {
abort(403);
}
-
+
$to = $request->input('to');
if ($to) {
$replaceConcept = Concept::findOrFail($to);
From ec2549c29692b5554d534c8c561797a16533b48b Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 12:46:37 -0400
Subject: [PATCH 08/23] fix: correct api route model binding
---
routes/api.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/routes/api.php b/routes/api.php
index a4caa45..6ab7e63 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -22,8 +22,8 @@
Route::get('concepts/reconcile/{id}', 'API\ConceptController@reconcile');
Route::get('concepts/reconcile', 'API\ConceptController@reconcile');
-Route::put('concepts/{id}/relate_concept', 'API\ConceptController@relateConcepts');
-Route::put('concepts/{id}/deprecate', 'API\ConceptController@deprecate');
+Route::put('concepts/{concept}/relate_concept', 'API\ConceptController@relateConcepts');
+Route::put('concepts/{concept}/deprecate', 'API\ConceptController@deprecate');
Route::apiResource('concepts', 'API\ConceptController');
Route::get('concepts_summary', function () {
From c5a95734d7f9c83c34e38deadd8fc0109b675c81 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 12:57:29 -0400
Subject: [PATCH 09/23] feat: add relateConcept method to ConceptService
---
resources/js/api/ConceptService.js | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js
index 94beea5..d880f26 100644
--- a/resources/js/api/ConceptService.js
+++ b/resources/js/api/ConceptService.js
@@ -56,4 +56,16 @@ export default {
return [error, null];
}
},
+
+ async relateConcept(conceptId, relationshipData) {
+ try {
+ const { data } = await apiClient.put(`/${conceptId}/relate_concept`, {
+ relation_type: relationshipData.type,
+ related_id: relationshipData.relatedId
+ });
+ return [null, data];
+ } catch (error) {
+ return [error, null];
+ }
+ },
};
From fe54725f9ba52ebe703fa62c4b74231f39b5e33b Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 13:00:24 -0400
Subject: [PATCH 10/23] feat: Add relationship management functionality
---
resources/js/components/Concept/Default.js | 3 +-
resources/js/components/Concept/Default.vue | 99 +++++++++++++++++++
.../components/Concept/mixins/Relationship.js | 69 +++++++++++++
3 files changed, 170 insertions(+), 1 deletion(-)
create mode 100644 resources/js/components/Concept/mixins/Relationship.js
diff --git a/resources/js/components/Concept/Default.js b/resources/js/components/Concept/Default.js
index 4fbb04b..41afed7 100644
--- a/resources/js/components/Concept/Default.js
+++ b/resources/js/components/Concept/Default.js
@@ -7,9 +7,10 @@ import MixinDirty from './mixins/Dirty';
import MixinEditMode from './mixins/EditMode';
import MixinSource from './mixins/Source';
import MixinTerm from './mixins/Term';
+import MixinRelationship from './mixins/Relationship';
export default {
- mixins: [MixinCategory, MixinDirty, MixinEditMode, MixinSource, MixinTerm],
+ mixins: [MixinCategory, MixinDirty, MixinEditMode, MixinSource, MixinTerm, MixinRelationship],
components: {
BModal,
BButton,
diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue
index c4cb515..927b5be 100644
--- a/resources/js/components/Concept/Default.vue
+++ b/resources/js/components/Concept/Default.vue
@@ -158,6 +158,89 @@
>
+
+
+
Relationships
+
+
+
+ Add Relationship
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Broader
+
+ -
+ {{ relation.preferredTerm.text }}
+
+
+
+
+
+
Narrower
+
+ -
+ {{ relation.preferredTerm.text }}
+
+
+
+
+
+
Related
+
+ -
+ {{ relation.preferredTerm.text }}
+
+
+
+
@@ -197,4 +280,20 @@ header.sticky-top {
top: 56px; /* offset for top navigation */
padding-top: 0.5rem;
}
+
+.search-result {
+ cursor: pointer;
+ border: 1px solid #ddd;
+ margin-bottom: 4px;
+ border-radius: 4px;
+}
+
+.search-result:hover {
+ background-color: #f8f9fa;
+}
+
+.search-result.selected {
+ background-color: #e9ecef;
+ border-color: #007bff;
+}
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js
new file mode 100644
index 0000000..2ef43bd
--- /dev/null
+++ b/resources/js/components/Concept/mixins/Relationship.js
@@ -0,0 +1,69 @@
+import ConceptService from '../../../api/ConceptService';
+
+export default {
+ data() {
+ return {
+ showRelationshipModal: false,
+ searchTerm: '',
+ searchResults: [],
+ selectedConcept: null,
+ relationshipType: null,
+ relationshipTypes: [
+ { value: 'broader', text: 'Broader' },
+ { value: 'narrower', text: 'Narrower' },
+ { value: 'related', text: 'Related' }
+ ],
+ isSearching: false
+ };
+ },
+
+ methods: {
+ showAddRelationship() {
+ this.showRelationshipModal = true;
+ this.searchTerm = '';
+ this.searchResults = [];
+ this.selectedConcept = null;
+ this.relationshipType = null;
+ },
+
+ async searchConcepts() {
+ if (this.searchTerm.length < 2) return;
+
+ this.isSearching = true;
+ try {
+ const response = await axios.get(`/api/concepts`, {
+ params: {
+ search: this.searchTerm
+ }
+ });
+ this.searchResults = response.data.data.filter(c => c.id !== this.conceptId);
+ } catch (error) {
+ console.error('Search failed:', error);
+ }
+ this.isSearching = false;
+ },
+
+ selectConcept(concept) {
+ this.selectedConcept = concept;
+ },
+
+ async saveRelationship() {
+ if (!this.selectedConcept || !this.relationshipType) return;
+
+ const relationshipData = {
+ type: this.relationshipType,
+ relatedId: this.selectedConcept.id
+ };
+
+ const [error, data] = await ConceptService.relateConcept(this.conceptId, relationshipData);
+
+ if (error) {
+ console.error('Failed to create relationship:', error);
+ return;
+ }
+
+ this.showRelationshipModal = false;
+ this.flashSuccessAlert();
+ }
+ }
+};
From 04941471c184b3f990a729a3bd3370871e4f6c8b Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 13:01:01 -0400
Subject: [PATCH 11/23] feat: Improve search functionality and data loading in
Relationship mixin
---
.../components/Concept/mixins/Relationship.js | 23 ++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js
index 2ef43bd..1a9f62c 100644
--- a/resources/js/components/Concept/mixins/Relationship.js
+++ b/resources/js/components/Concept/mixins/Relationship.js
@@ -17,6 +17,16 @@ export default {
};
},
+ computed: {
+ relationships() {
+ return {
+ broader: this.concept?.broader || [],
+ narrower: this.concept?.narrower || [],
+ related: this.concept?.related || []
+ };
+ }
+ },
+
methods: {
showAddRelationship() {
this.showRelationshipModal = true;
@@ -27,18 +37,25 @@ export default {
},
async searchConcepts() {
- if (this.searchTerm.length < 2) return;
+ if (this.searchTerm.length < 2) {
+ this.searchResults = [];
+ return;
+ }
this.isSearching = true;
try {
const response = await axios.get(`/api/concepts`, {
params: {
- search: this.searchTerm
+ term: this.searchTerm,
+ per_page: 10
}
});
- this.searchResults = response.data.data.filter(c => c.id !== this.conceptId);
+ this.searchResults = response.data.data.filter(c =>
+ c.id !== this.conceptId && !c.deprecated
+ );
} catch (error) {
console.error('Search failed:', error);
+ this.searchResults = [];
}
this.isSearching = false;
},
From 14bfbc271ecbe85784359bb3d2612e32a555ca1e Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 15:25:30 -0400
Subject: [PATCH 12/23] feat: Add searchConcepts method to ConceptService
---
resources/js/api/ConceptService.js | 14 +++++++++++++
.../components/Concept/mixins/Relationship.js | 20 +++++++++----------
2 files changed, 23 insertions(+), 11 deletions(-)
diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js
index d880f26..709fed2 100644
--- a/resources/js/api/ConceptService.js
+++ b/resources/js/api/ConceptService.js
@@ -57,6 +57,20 @@ export default {
}
},
+ async searchConcepts(searchTerm, perPage = 10) {
+ try {
+ const { data } = await apiClient.get('', {
+ params: {
+ term: searchTerm,
+ per_page: perPage
+ }
+ });
+ return [null, data];
+ } catch (error) {
+ return [error, null];
+ }
+ },
+
async relateConcept(conceptId, relationshipData) {
try {
const { data } = await apiClient.put(`/${conceptId}/relate_concept`, {
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js
index 1a9f62c..900c193 100644
--- a/resources/js/components/Concept/mixins/Relationship.js
+++ b/resources/js/components/Concept/mixins/Relationship.js
@@ -43,20 +43,18 @@ export default {
}
this.isSearching = true;
- try {
- const response = await axios.get(`/api/concepts`, {
- params: {
- term: this.searchTerm,
- per_page: 10
- }
- });
- this.searchResults = response.data.data.filter(c =>
- c.id !== this.conceptId && !c.deprecated
- );
- } catch (error) {
+ const [error, response] = await ConceptService.searchConcepts(this.searchTerm);
+
+ if (error) {
console.error('Search failed:', error);
this.searchResults = [];
+ } else {
+ // Filter out current concept and deprecated concepts
+ this.searchResults = response.data.filter(c =>
+ c.id !== this.conceptId && !c.deprecated
+ );
}
+
this.isSearching = false;
},
From 7b5f40ce1fc470c28c20b792f54c08f064daee9a Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 16:20:08 -0400
Subject: [PATCH 13/23] feat: Add search endpoint to ConceptController
---
.../Controllers/API/ConceptController.php | 40 +++++++++++++++++++
routes/api.php | 1 +
2 files changed, 41 insertions(+)
diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php
index fe60b21..27b917b 100644
--- a/app/Http/Controllers/API/ConceptController.php
+++ b/app/Http/Controllers/API/ConceptController.php
@@ -233,6 +233,46 @@ public function destroy(Concept $concept)
return response('Deleted ' . $concept->id, 204);
}
+ /**
+ * Search concepts by term.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
+ */
+ public function search(Request $request)
+ {
+ $request->validate([
+ 'term' => 'required|string|min:2',
+ 'all_terms' => 'boolean',
+ 'category' => 'nullable|string',
+ 'per_page' => 'nullable|integer|min:1|max:100'
+ ]);
+
+ $query = Concept::with(['terms', 'conceptCategories'])
+ ->select('concepts.*')
+ ->join('terms', 'concepts.id', '=', 'terms.concept_id')
+ ->leftJoin('concept_categories', 'concepts.id', '=', 'concept_categories.concept_id')
+ ->leftJoin('vocabulary', 'concept_categories.category_id', '=', 'vocabulary.id')
+ ->where('terms.text', 'ILIKE', '%' . $request->term . '%')
+ ->where('concepts.deprecated', false);
+
+ if (!$request->boolean('all_terms', false)) {
+ $query->where('terms.preferred', true);
+ }
+
+ if ($request->filled('category')) {
+ $query->where('vocabulary.value', 'ILIKE', $request->category);
+ }
+
+ $query->distinct();
+
+ $perPage = $request->input('per_page', 15);
+
+ return ConceptResource::collection(
+ $query->paginate($perPage)
+ );
+ }
+
/**
* Reconcile Concept for OpenRefine
*
diff --git a/routes/api.php b/routes/api.php
index 6ab7e63..3a2682b 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -19,6 +19,7 @@
return $request->user();
});
+Route::get('concepts/search', 'API\ConceptController@search');
Route::get('concepts/reconcile/{id}', 'API\ConceptController@reconcile');
Route::get('concepts/reconcile', 'API\ConceptController@reconcile');
From 6ae79598dd1232ca40b79bc141bb9648acee085a Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 16:23:37 -0400
Subject: [PATCH 14/23] feat: add comprehensive concept search test
---
tests/Feature/API/ConceptsTest.php | 42 ++++++++++++++++++++++++++++--
1 file changed, 40 insertions(+), 2 deletions(-)
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php
index dcf4d03..db94424 100644
--- a/tests/Feature/API/ConceptsTest.php
+++ b/tests/Feature/API/ConceptsTest.php
@@ -4,6 +4,7 @@
use App\Models\Concept;
use App\Models\Role;
+use App\Models\Term;
use App\Models\User;
use App\Models\Vocabulary;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@@ -23,8 +24,45 @@ public function test_any_can_list_concepts(): void
public function test_any_can_search_concepts(): void
{
- $response = $this->getJson('/api/concepts?search=example');
- $response->assertStatus(200);
+ // Create test concepts with terms
+ $concept1 = Concept::factory()->create(['deprecated' => false]);
+ $concept2 = Concept::factory()->create(['deprecated' => false]);
+ $deprecatedConcept = Concept::factory()->create(['deprecated' => true]);
+
+ // Create terms for the concepts
+ $term1 = Term::create([
+ 'concept_id' => $concept1->id,
+ 'text' => 'test search term',
+ 'preferred' => true
+ ]);
+
+ $term2 = Term::create([
+ 'concept_id' => $concept2->id,
+ 'text' => 'another test term',
+ 'preferred' => false
+ ]);
+
+ $term3 = Term::create([
+ 'concept_id' => $deprecatedConcept->id,
+ 'text' => 'test deprecated term',
+ 'preferred' => true
+ ]);
+
+ // Test basic search
+ $response = $this->getJson('/api/concepts/search?term=test');
+ $response->assertStatus(200)
+ ->assertJsonCount(1, 'data') // Only preferred terms by default
+ ->assertJsonPath('data.0.id', $concept1->id);
+
+ // Test search with all_terms=true
+ $response = $this->getJson('/api/concepts/search?term=test&all_terms=true');
+ $response->assertStatus(200)
+ ->assertJsonCount(2, 'data') // Both preferred and non-preferred terms
+ ->assertJsonMissing(['id' => $deprecatedConcept->id]); // Deprecated concepts should not appear
+
+ // Test validation
+ $response = $this->getJson('/api/concepts/search?term=a');
+ $response->assertStatus(422); // Should fail validation for min:2
}
public function test_any_can_get_concept(): void
From fcf604f2b822c0f2c4edf368ac42fca0635a2d36 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 16:53:01 -0400
Subject: [PATCH 15/23] feat: update search endpoint testing, change
conceptservice to use new search endpoint
---
.../Controllers/API/ConceptController.php | 6 +-
resources/js/api/ConceptService.js | 8 +--
resources/js/components/Concept/Default.vue | 58 +++++++++++--------
tests/Feature/API/ConceptsTest.php | 22 +++----
4 files changed, 51 insertions(+), 43 deletions(-)
diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php
index 27b917b..590119b 100644
--- a/app/Http/Controllers/API/ConceptController.php
+++ b/app/Http/Controllers/API/ConceptController.php
@@ -20,7 +20,7 @@ class ConceptController extends Controller
*/
public function __construct()
{
- $this->middleware('auth:sanctum')->except(['index', 'show', 'reconcile']);
+ $this->middleware('auth:sanctum')->except(['index', 'show', 'reconcile', 'search']);
$this->authorizeResource(Concept::class);
}
@@ -245,7 +245,7 @@ public function search(Request $request)
'term' => 'required|string|min:2',
'all_terms' => 'boolean',
'category' => 'nullable|string',
- 'per_page' => 'nullable|integer|min:1|max:100'
+ 'per_page' => 'nullable|integer|min:1|max:100',
]);
$query = Concept::with(['terms', 'conceptCategories'])
@@ -267,7 +267,7 @@ public function search(Request $request)
$query->distinct();
$perPage = $request->input('per_page', 15);
-
+
return ConceptResource::collection(
$query->paginate($perPage)
);
diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js
index 709fed2..de5072e 100644
--- a/resources/js/api/ConceptService.js
+++ b/resources/js/api/ConceptService.js
@@ -59,11 +59,11 @@ export default {
async searchConcepts(searchTerm, perPage = 10) {
try {
- const { data } = await apiClient.get('', {
+ const { data } = await apiClient.get('/search', {
params: {
term: searchTerm,
- per_page: perPage
- }
+ per_page: perPage,
+ },
});
return [null, data];
} catch (error) {
@@ -75,7 +75,7 @@ export default {
try {
const { data } = await apiClient.put(`/${conceptId}/relate_concept`, {
relation_type: relationshipData.type,
- related_id: relationshipData.relatedId
+ related_id: relationshipData.relatedId,
});
return [null, data];
} catch (error) {
diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue
index 927b5be..bb3eb40 100644
--- a/resources/js/components/Concept/Default.vue
+++ b/resources/js/components/Concept/Default.vue
@@ -29,8 +29,15 @@
hide-footer
>
-
You have unsaved changes. Are you sure you want to exit Edit Mode?
-
Yes, exit
+
+ You have unsaved changes. Are you sure you want to exit Edit Mode?
+
+
Yes, exit
No
@@ -64,7 +71,9 @@
{{ preferredTerm.text }}
+ >
+ {{ preferredTerm.text }}
+
Alternate Terms
@@ -129,9 +138,7 @@
v-bind:index="index"
>
-
+
{{ cat.value }}
Add Category
@@ -161,13 +170,9 @@
Relationships
-
+
-
+
Add Relationship
@@ -194,48 +199,51 @@
@input="searchConcepts"
placeholder="Type to search..."
>
-
+
Searching...
-
- {{ concept.preferredTerm.text }}
+ {{ concept.preferred_term.text }}
-
+
Broader
- -
+
-
{{ relation.preferredTerm.text }}
-
+
Narrower
- -
+
-
{{ relation.preferredTerm.text }}
-
+
Related
- -
+
-
{{ relation.preferredTerm.text }}
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php
index db94424..797937c 100644
--- a/tests/Feature/API/ConceptsTest.php
+++ b/tests/Feature/API/ConceptsTest.php
@@ -28,34 +28,34 @@ public function test_any_can_search_concepts(): void
$concept1 = Concept::factory()->create(['deprecated' => false]);
$concept2 = Concept::factory()->create(['deprecated' => false]);
$deprecatedConcept = Concept::factory()->create(['deprecated' => true]);
-
+
// Create terms for the concepts
$term1 = Term::create([
'concept_id' => $concept1->id,
- 'text' => 'test search term',
- 'preferred' => true
+ 'text' => 'test search term 1',
+ 'preferred' => true,
]);
-
+
$term2 = Term::create([
'concept_id' => $concept2->id,
- 'text' => 'another test term',
- 'preferred' => false
+ 'text' => 'test search term 2',
+ 'preferred' => false,
]);
-
+
$term3 = Term::create([
'concept_id' => $deprecatedConcept->id,
- 'text' => 'test deprecated term',
- 'preferred' => true
+ 'text' => 'test search term deprecated',
+ 'preferred' => true,
]);
// Test basic search
- $response = $this->getJson('/api/concepts/search?term=test');
+ $response = $this->getJson('/api/concepts/search?term=test%20search%20term');
$response->assertStatus(200)
->assertJsonCount(1, 'data') // Only preferred terms by default
->assertJsonPath('data.0.id', $concept1->id);
// Test search with all_terms=true
- $response = $this->getJson('/api/concepts/search?term=test&all_terms=true');
+ $response = $this->getJson('/api/concepts/search?term=test%20search%20term&all_terms=1');
$response->assertStatus(200)
->assertJsonCount(2, 'data') // Both preferred and non-preferred terms
->assertJsonMissing(['id' => $deprecatedConcept->id]); // Deprecated concepts should not appear
From 8c3f29c2ad751ad91ff59e6b3b5c406f33ab7a1d Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 18:52:26 -0400
Subject: [PATCH 16/23] fix: update preferred_term property in
Concept/Default.vue
---
resources/js/components/Concept/Default.vue | 6 +--
.../components/Concept/mixins/Relationship.js | 43 +++++++++++--------
2 files changed, 29 insertions(+), 20 deletions(-)
diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue
index bb3eb40..9f371d6 100644
--- a/resources/js/components/Concept/Default.vue
+++ b/resources/js/components/Concept/Default.vue
@@ -226,7 +226,7 @@
Broader
-
- {{ relation.preferredTerm.text }}
+ {{ relation.preferred_term.text }}
@@ -235,7 +235,7 @@
Narrower
-
- {{ relation.preferredTerm.text }}
+ {{ relation.preferred_term.text }}
@@ -244,7 +244,7 @@
Related
-
- {{ relation.preferredTerm.text }}
+ {{ relation.preferred_term.text }}
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js
index 900c193..1c5e8ed 100644
--- a/resources/js/components/Concept/mixins/Relationship.js
+++ b/resources/js/components/Concept/mixins/Relationship.js
@@ -11,20 +11,20 @@ export default {
relationshipTypes: [
{ value: 'broader', text: 'Broader' },
{ value: 'narrower', text: 'Narrower' },
- { value: 'related', text: 'Related' }
+ { value: 'related', text: 'Related' },
],
- isSearching: false
+ isSearching: false,
};
},
computed: {
relationships() {
return {
- broader: this.concept?.broader || [],
- narrower: this.concept?.narrower || [],
- related: this.concept?.related || []
+ broader: this.conceptProps?.broader || [],
+ narrower: this.conceptProps?.narrower || [],
+ related: this.conceptProps?.related || [],
};
- }
+ },
},
methods: {
@@ -41,20 +41,22 @@ export default {
this.searchResults = [];
return;
}
-
+
this.isSearching = true;
- const [error, response] = await ConceptService.searchConcepts(this.searchTerm);
-
+ const [error, response] = await ConceptService.searchConcepts(
+ this.searchTerm,
+ );
+
if (error) {
console.error('Search failed:', error);
this.searchResults = [];
} else {
// Filter out current concept and deprecated concepts
- this.searchResults = response.data.filter(c =>
- c.id !== this.conceptId && !c.deprecated
+ this.searchResults = response.data.filter(
+ (c) => c.id !== this.conceptId && !c.deprecated,
);
}
-
+
this.isSearching = false;
},
@@ -67,18 +69,25 @@ export default {
const relationshipData = {
type: this.relationshipType,
- relatedId: this.selectedConcept.id
+ relatedId: this.selectedConcept.id,
};
- const [error, data] = await ConceptService.relateConcept(this.conceptId, relationshipData);
-
+ const [error, data] = await ConceptService.relateConcept(
+ this.conceptId,
+ relationshipData,
+ );
+
if (error) {
console.error('Failed to create relationship:', error);
return;
}
+ this.concept.broader = data.broader;
+
+ console.log(data);
+
this.showRelationshipModal = false;
this.flashSuccessAlert();
- }
- }
+ },
+ },
};
From 532a1135c938cb78db9dac3625ba7a417695e1db Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 18:52:27 -0400
Subject: [PATCH 17/23] feat: Update relationships display in Default.vue
---
resources/js/components/Concept/Default.vue | 62 ++++++++++++-------
.../components/Concept/mixins/Relationship.js | 7 +++
2 files changed, 46 insertions(+), 23 deletions(-)
diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue
index 9f371d6..c5b2bcb 100644
--- a/resources/js/components/Concept/Default.vue
+++ b/resources/js/components/Concept/Default.vue
@@ -222,31 +222,27 @@
-
-
Broader
-
- -
- {{ relation.preferred_term.text }}
-
-
-
+
+
-
-
Narrower
-
- -
- {{ relation.preferred_term.text }}
-
-
-
+
-
-
Related
-
- -
- {{ relation.preferred_term.text }}
-
-
+
@@ -304,4 +300,24 @@ header.sticky-top {
background-color: #e9ecef;
border-color: #007bff;
}
+
+.relations {
+ margin-top: 1rem;
+}
+
+.relations h3 {
+ font-size: 1.25rem;
+ margin-bottom: 1rem;
+}
+
+.relations a {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: #2c5282;
+ text-decoration: none;
+}
+
+.relations a:hover {
+ text-decoration: underline;
+}
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js
index 1c5e8ed..b77ba33 100644
--- a/resources/js/components/Concept/mixins/Relationship.js
+++ b/resources/js/components/Concept/mixins/Relationship.js
@@ -25,6 +25,13 @@ export default {
related: this.conceptProps?.related || [],
};
},
+ hasAnyRelationships() {
+ return (
+ (this.relationships.broader && this.relationships.broader.length > 0) ||
+ (this.relationships.narrower && this.relationships.narrower.length > 0) ||
+ (this.relationships.related && this.relationships.related.length > 0)
+ );
+ }
},
methods: {
From d36129fa2e83377ccc5fda961d90dafd9589c4ff Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 18:57:04 -0400
Subject: [PATCH 18/23] feat: Update show view to allow vue to handle
relationship rendering
---
.../Controllers/API/ConceptController.php | 2 +-
.../components/Concept/mixins/Relationship.js | 23 +++++++++----------
resources/views/concepts/show.blade.php | 16 -------------
3 files changed, 12 insertions(+), 29 deletions(-)
diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php
index 590119b..b07b5c6 100644
--- a/app/Http/Controllers/API/ConceptController.php
+++ b/app/Http/Controllers/API/ConceptController.php
@@ -190,7 +190,7 @@ public function relateConcepts(Request $request, Concept $concept)
break;
}
- return $concept;
+ return $concept->loadMissing(['broader', 'narrower', 'related']);
}
/**
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js
index b77ba33..741615f 100644
--- a/resources/js/components/Concept/mixins/Relationship.js
+++ b/resources/js/components/Concept/mixins/Relationship.js
@@ -13,25 +13,24 @@ export default {
{ value: 'narrower', text: 'Narrower' },
{ value: 'related', text: 'Related' },
],
+ relationships: {
+ broader: this.conceptProps?.broader || [],
+ narrower: this.conceptProps?.narrower || [],
+ related: this.conceptProps?.related || [],
+ },
isSearching: false,
};
},
computed: {
- relationships() {
- return {
- broader: this.conceptProps?.broader || [],
- narrower: this.conceptProps?.narrower || [],
- related: this.conceptProps?.related || [],
- };
- },
hasAnyRelationships() {
return (
(this.relationships.broader && this.relationships.broader.length > 0) ||
- (this.relationships.narrower && this.relationships.narrower.length > 0) ||
+ (this.relationships.narrower &&
+ this.relationships.narrower.length > 0) ||
(this.relationships.related && this.relationships.related.length > 0)
);
- }
+ },
},
methods: {
@@ -89,9 +88,9 @@ export default {
return;
}
- this.concept.broader = data.broader;
-
- console.log(data);
+ this.relationships.broader = data.broader;
+ this.relationships.narrower = data.narrower;
+ this.relationships.related = data.related;
this.showRelationshipModal = false;
this.flashSuccessAlert();
diff --git a/resources/views/concepts/show.blade.php b/resources/views/concepts/show.blade.php
index d9d8310..c10cb8c 100644
--- a/resources/views/concepts/show.blade.php
+++ b/resources/views/concepts/show.blade.php
@@ -31,21 +31,5 @@
can-edit-vocabulary="{{ json_encode($isVocabularyEditor) }}"
>
-
- @if ( count($relations) )
-
- Relations
-
-
- @foreach($relations as $title => $terms)
-
-
{{ $title }}
- @foreach($terms as $term)
-
- @endforeach
-
- @endforeach
-
- @endif
@endsection
From 331d2b261157d775c0a22057e5575529d2356cf5 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 19:03:16 -0400
Subject: [PATCH 19/23] feat: Add relationship removal functionality
---
.../Controllers/API/ConceptController.php | 35 +++++++++++++++++++
resources/js/api/ConceptService.js | 14 ++++++++
routes/api.php | 1 +
3 files changed, 50 insertions(+)
diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php
index b07b5c6..3668763 100644
--- a/app/Http/Controllers/API/ConceptController.php
+++ b/app/Http/Controllers/API/ConceptController.php
@@ -193,6 +193,41 @@ public function relateConcepts(Request $request, Concept $concept)
return $concept->loadMissing(['broader', 'narrower', 'related']);
}
+ /**
+ * Remove a relationship between concepts.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param \App\Concept $concept
+ * @return \Illuminate\Http\Response
+ */
+ public function removeRelationship(Request $request, Concept $concept)
+ {
+ if ($request->user()->cannot('update', $concept)) {
+ abort(403);
+ }
+
+ $relation_type = $request->input('relation_type');
+ $related_id = $request->input('related_id');
+
+ switch ($relation_type) {
+ case "broader":
+ $concept->broader()->detach($related_id);
+ break;
+ case "narrower":
+ $concept->narrower()->detach($related_id);
+ break;
+ case "related":
+ // Remove both directions for related relationships
+ $concept->related()->detach($related_id);
+ $concept->belongsToMany("App\Models\Concept", "concept_relationships", "related_concept_id", "concept_id")
+ ->wherePivot("relationship_type", "related")
+ ->detach($related_id);
+ break;
+ }
+
+ return $concept->loadMissing(['broader', 'narrower', 'related']);
+ }
+
/**
* Display the specified resource.
*
diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js
index de5072e..1b87fe8 100644
--- a/resources/js/api/ConceptService.js
+++ b/resources/js/api/ConceptService.js
@@ -82,4 +82,18 @@ export default {
return [error, null];
}
},
+
+ async removeRelationship(conceptId, relationshipData) {
+ try {
+ const { data } = await apiClient.delete(`/${conceptId}/relate_concept`, {
+ data: { // Using data property for DELETE request body
+ relation_type: relationshipData.type,
+ related_id: relationshipData.relatedId,
+ }
+ });
+ return [null, data];
+ } catch (error) {
+ return [error, null];
+ }
+ },
};
diff --git a/routes/api.php b/routes/api.php
index 3a2682b..20cea04 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -24,6 +24,7 @@
Route::get('concepts/reconcile', 'API\ConceptController@reconcile');
Route::put('concepts/{concept}/relate_concept', 'API\ConceptController@relateConcepts');
+Route::delete('concepts/{concept}/relate_concept', 'API\ConceptController@removeRelationship');
Route::put('concepts/{concept}/deprecate', 'API\ConceptController@deprecate');
Route::apiResource('concepts', 'API\ConceptController');
From 855c9b9db8cd654151bc8d20000d52914c6995af Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 19:03:58 -0400
Subject: [PATCH 20/23] feat: Add ability to remove concept relationships
---
resources/js/components/Concept/Default.vue | 45 +++++++++++++++++--
.../components/Concept/mixins/Relationship.js | 27 +++++++++++
2 files changed, 69 insertions(+), 3 deletions(-)
diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue
index c5b2bcb..ec99113 100644
--- a/resources/js/components/Concept/Default.vue
+++ b/resources/js/components/Concept/Default.vue
@@ -226,21 +226,51 @@
@@ -320,4 +350,13 @@ header.sticky-top {
.relations a:hover {
text-decoration: underline;
}
+
+.relations .d-flex {
+ margin-bottom: 0.5rem;
+}
+
+.relations .btn-sm {
+ padding: 0.25rem 0.5rem;
+ margin-left: 0.5rem;
+}
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js
index 741615f..8d65fbf 100644
--- a/resources/js/components/Concept/mixins/Relationship.js
+++ b/resources/js/components/Concept/mixins/Relationship.js
@@ -95,5 +95,32 @@ export default {
this.showRelationshipModal = false;
this.flashSuccessAlert();
},
+
+ async removeRelationship(type, relatedId) {
+ if (!confirm('Are you sure you want to remove this relationship?')) {
+ return;
+ }
+
+ const relationshipData = {
+ type: type,
+ relatedId: relatedId,
+ };
+
+ const [error, data] = await ConceptService.removeRelationship(
+ this.conceptId,
+ relationshipData,
+ );
+
+ if (error) {
+ console.error('Failed to remove relationship:', error);
+ return;
+ }
+
+ this.relationships.broader = data.broader;
+ this.relationships.narrower = data.narrower;
+ this.relationships.related = data.related;
+
+ this.flashSuccessAlert();
+ },
},
};
From 5d8c18551fdff2a0ad2f2aacfecb48a901adfbbe Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 19:59:09 -0400
Subject: [PATCH 21/23] feat: Add test coverage for remove concept
relationships
---
tests/Feature/API/ConceptsTest.php | 109 +++++++++++++++++++++++++++++
1 file changed, 109 insertions(+)
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php
index 797937c..d885a03 100644
--- a/tests/Feature/API/ConceptsTest.php
+++ b/tests/Feature/API/ConceptsTest.php
@@ -268,4 +268,113 @@ public function test_unauthorized_user_cannot_delete_concept(): void
$response->assertStatus(403);
}
+
+ public function test_authorized_user_can_remove_concept_relationships(): void
+ {
+ $reviewerRole = Role::whereHas('permissions', function ($query) {
+ $query->where('label', 'Edit Vocabulary');
+ })->first();
+ $user = User::factory()->hasAttached($reviewerRole)->create();
+ Sanctum::actingAs($user);
+
+ // Create test concepts
+ $concept = Concept::factory()->create();
+ $broaderConcept = Concept::factory()->create();
+ $narrowerConcept = Concept::factory()->create();
+ $relatedConcept = Concept::factory()->create();
+
+ // Create relationships first
+ $concept->addBroader($broaderConcept->id);
+ $concept->addNarrower($narrowerConcept->id);
+ $concept->addRelated($relatedConcept->id);
+
+ // Test removing broader relationship
+ $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'broader',
+ 'related_id' => $broaderConcept->id,
+ ]);
+ $response->assertStatus(200);
+ $this->assertFalse($concept->fresh()->broader->contains($broaderConcept));
+
+ // Test removing narrower relationship
+ $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'narrower',
+ 'related_id' => $narrowerConcept->id,
+ ]);
+ $response->assertStatus(200);
+ $this->assertFalse($concept->fresh()->narrower->contains($narrowerConcept));
+
+ // Test removing related relationship
+ $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'related',
+ 'related_id' => $relatedConcept->id,
+ ]);
+ $response->assertStatus(200);
+ $this->assertFalse($concept->fresh()->related->contains($relatedConcept));
+ }
+
+ public function test_unauthorized_user_cannot_remove_concept_relationships(): void
+ {
+ $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) {
+ $query->where('label', 'Edit Vocabulary');
+ })->first();
+ $user = User::factory()->hasAttached($nonReviewerRole)->create();
+ Sanctum::actingAs($user);
+
+ // Create test concepts
+ $concept = Concept::factory()->create();
+ $relatedConcept = Concept::factory()->create();
+
+ // Create a relationship first
+ $concept->addRelated($relatedConcept->id);
+
+ // Attempt to remove the relationship
+ $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'related',
+ 'related_id' => $relatedConcept->id,
+ ]);
+
+ $response->assertStatus(403);
+ $this->assertTrue($concept->fresh()->related->contains($relatedConcept));
+ }
+
+ public function test_removing_nonexistent_relationship_returns_success(): void
+ {
+ $reviewerRole = Role::whereHas('permissions', function ($query) {
+ $query->where('label', 'Edit Vocabulary');
+ })->first();
+ $user = User::factory()->hasAttached($reviewerRole)->create();
+ Sanctum::actingAs($user);
+
+ $concept = Concept::factory()->create();
+ $nonRelatedConcept = Concept::factory()->create();
+
+ // Attempt to remove a relationship that doesn't exist
+ $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'related',
+ 'related_id' => $nonRelatedConcept->id,
+ ]);
+
+ $response->assertStatus(200);
+ }
+
+ public function test_removing_relationship_with_invalid_type_returns_error(): void
+ {
+ $reviewerRole = Role::whereHas('permissions', function ($query) {
+ $query->where('label', 'Edit Vocabulary');
+ })->first();
+ $user = User::factory()->hasAttached($reviewerRole)->create();
+ Sanctum::actingAs($user);
+
+ $concept = Concept::factory()->create();
+ $relatedConcept = Concept::factory()->create();
+
+ // Attempt to remove a relationship with invalid type
+ $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'invalid_type',
+ 'related_id' => $relatedConcept->id,
+ ]);
+
+ $response->assertStatus(422);
+ }
}
From b03a5ff328d476b1250276c33ef740ddb84b2eb4 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 20:02:06 -0400
Subject: [PATCH 22/23] feat: add request validation to relationship removal
---
app/Http/Controllers/API/ConceptController.php | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php
index 3668763..fe479d4 100644
--- a/app/Http/Controllers/API/ConceptController.php
+++ b/app/Http/Controllers/API/ConceptController.php
@@ -206,6 +206,11 @@ public function removeRelationship(Request $request, Concept $concept)
abort(403);
}
+ $request->validate([
+ 'relation_type' => 'required|in:broader,narrower,related',
+ 'related_id' => 'required|exists:concepts,id',
+ ]);
+
$relation_type = $request->input('relation_type');
$related_id = $request->input('related_id');
From dada3310d560b995c00d590e7fda546fb0fce9c4 Mon Sep 17 00:00:00 2001
From: Nevin Morgan
Date: Mon, 28 Oct 2024 20:11:11 -0400
Subject: [PATCH 23/23] feat: add request validation and testing to
relateConcepts method
---
.../Controllers/API/ConceptController.php | 5 +++
tests/Feature/API/ConceptsTest.php | 39 +++++++++++++++++++
2 files changed, 44 insertions(+)
diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php
index fe479d4..55ede21 100644
--- a/app/Http/Controllers/API/ConceptController.php
+++ b/app/Http/Controllers/API/ConceptController.php
@@ -175,6 +175,11 @@ public function relateConcepts(Request $request, Concept $concept)
abort(403);
}
+ $request->validate([
+ 'relation_type' => 'required|in:broader,narrower,related',
+ 'related_id' => 'required|exists:concepts,id',
+ ]);
+
$relation_type = $request->input('relation_type');
$related_id = $request->input('related_id');
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php
index d885a03..1fb6354 100644
--- a/tests/Feature/API/ConceptsTest.php
+++ b/tests/Feature/API/ConceptsTest.php
@@ -163,6 +163,45 @@ public function test_authorized_user_can_update_concept_relationships(): void
$this->assertTrue($concept->related->contains($relatedConcept));
}
+ public function test_relate_concepts_validates_relation_type(): void
+ {
+ $reviewerRole = Role::whereHas('permissions', function ($query) {
+ $query->where('label', 'Edit Vocabulary');
+ })->first();
+ $user = User::factory()->hasAttached($reviewerRole)->create();
+ Sanctum::actingAs($user);
+
+ $concept = Concept::factory()->create();
+ $relatedConcept = Concept::factory()->create();
+
+ $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'invalid_type',
+ 'related_id' => $relatedConcept->id,
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['relation_type']);
+ }
+
+ public function test_relate_concepts_validates_related_id(): void
+ {
+ $reviewerRole = Role::whereHas('permissions', function ($query) {
+ $query->where('label', 'Edit Vocabulary');
+ })->first();
+ $user = User::factory()->hasAttached($reviewerRole)->create();
+ Sanctum::actingAs($user);
+
+ $concept = Concept::factory()->create();
+
+ $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [
+ 'relation_type' => 'broader',
+ 'related_id' => 99999999999999, // Non-existent ID
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['related_id']);
+ }
+
public function test_authorized_user_can_deprecate_concept(): void
{
$reviewerRole = Role::whereHas('permissions', function ($query) {