Skip to content

Commit 4f9740e

Browse files
committed
feat: entity linker component
1 parent d703df9 commit 4f9740e

File tree

8 files changed

+757
-5
lines changed

8 files changed

+757
-5
lines changed

valentine/lib/valentine/composer.ex

+42
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,27 @@ defmodule Valentine.Composer do
666666
end
667667
end
668668

669+
def add_threat_to_assumption(%Assumption{} = assumption, %Threat{} = threat) do
670+
%AssumptionThreat{assumption_id: assumption.id, threat_id: threat.id}
671+
|> Repo.insert()
672+
|> case do
673+
{:ok, _} -> {:ok, assumption |> Repo.preload(:threats, force: true)}
674+
{:error, _} -> {:error, assumption}
675+
end
676+
end
677+
678+
def remove_threat_from_assumption(%Assumption{} = assumption, %Threat{} = threat) do
679+
Repo.delete_all(
680+
from(at in AssumptionThreat,
681+
where: at.assumption_id == ^assumption.id and at.threat_id == ^threat.id
682+
)
683+
)
684+
|> case do
685+
{1, nil} -> {:ok, assumption |> Repo.preload(:threats, force: true)}
686+
{:error, _} -> {:error, assumption}
687+
end
688+
end
689+
669690
def add_assumption_to_mitigation(%Mitigation{} = mitigation, %Assumption{} = assumption) do
670691
%AssumptionMitigation{assumption_id: assumption.id, mitigation_id: mitigation.id}
671692
|> Repo.insert()
@@ -687,6 +708,27 @@ defmodule Valentine.Composer do
687708
end
688709
end
689710

711+
def add_threat_to_mitigation(%Mitigation{} = mitigation, %Threat{} = threat) do
712+
%MitigationThreat{mitigation_id: mitigation.id, threat_id: threat.id}
713+
|> Repo.insert()
714+
|> case do
715+
{:ok, _} -> {:ok, mitigation |> Repo.preload(:threats, force: true)}
716+
{:error, _} -> {:error, mitigation}
717+
end
718+
end
719+
720+
def remove_threat_from_mitigation(%Mitigation{} = mitigation, %Threat{} = threat) do
721+
Repo.delete_all(
722+
from(mt in MitigationThreat,
723+
where: mt.mitigation_id == ^mitigation.id and mt.threat_id == ^threat.id
724+
)
725+
)
726+
|> case do
727+
{1, nil} -> {:ok, mitigation |> Repo.preload(:threats, force: true)}
728+
{:error, _} -> {:error, mitigation}
729+
end
730+
end
731+
690732
def add_mitigation_to_assumption(%Assumption{} = assumption, %Mitigation{} = mitigation) do
691733
%AssumptionMitigation{assumption_id: assumption.id, mitigation_id: mitigation.id}
692734
|> Repo.insert()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
defmodule ValentineWeb.WorkspaceLive.Components.EntityLinkerComponent do
2+
use ValentineWeb, :live_component
3+
use PrimerLive
4+
5+
@impl true
6+
def render(assigns) do
7+
~H"""
8+
<div>
9+
<.dialog id="linker-modal" is_backdrop is_show is_wide on_cancel={JS.patch(@patch)}>
10+
<:header_title>
11+
{gettext("Link %{source_entity_type} to %{target_entity_type}",
12+
source_entity_type: Atom.to_string(@source_entity_type),
13+
target_entity_type: Atom.to_string(@target_entity_type)
14+
)}
15+
</:header_title>
16+
<:body>
17+
<.live_component
18+
module={ValentineWeb.WorkspaceLive.Components.DropdownSelectComponent}
19+
id="link-dropdown"
20+
name={Atom.to_string(@target_entity_type)}
21+
target={@myself}
22+
items={
23+
(@linkable_entities -- @linked_entities)
24+
|> Enum.map(fn e -> %{id: e.id, name: entity_content(e)} end)
25+
}
26+
/>
27+
<div class="mt-2">
28+
<%= for entity <- @linked_entities || [] do %>
29+
<.button
30+
phx-click="remove_entity"
31+
phx-target={@myself}
32+
phx-value-id={entity.id}
33+
class="tag-button mt-2"
34+
>
35+
<span>{entity_content(entity)}</span>
36+
<.octicon name="x-16" />
37+
</.button>
38+
<% end %>
39+
</div>
40+
</:body>
41+
<:footer>
42+
<.button is_primary phx-click="save" phx-target={@myself}>
43+
{gettext("Save")}
44+
</.button>
45+
<.button phx-click={cancel_dialog("linker-modal")}>{gettext("Cancel")}</.button>
46+
</:footer>
47+
</.dialog>
48+
</div>
49+
"""
50+
end
51+
52+
@impl true
53+
def handle_event("remove_entity", %{"id" => id}, socket) do
54+
entity = Enum.find(socket.assigns.linked_entities, fn t -> t.id == id end)
55+
56+
{:noreply,
57+
socket
58+
|> assign(:linked_entities, socket.assigns.linked_entities -- [entity])
59+
|> assign(:linkable_entities, [entity | socket.assigns.linkable_entities])}
60+
end
61+
62+
@impl true
63+
def handle_event("save", _params, socket) do
64+
%{
65+
entity: entity,
66+
linked_entities: linked_entities,
67+
source_entity_type: source_entity_type,
68+
target_entity_type: target_entity_type
69+
} =
70+
socket.assigns
71+
72+
current = get_in(entity, [Access.key!(target_entity_type)])
73+
74+
to_add = linked_entities -- current
75+
to_remove = current -- linked_entities
76+
77+
{adder, remover} =
78+
case {source_entity_type, target_entity_type} do
79+
{:assumption, :mitigations} ->
80+
{&Valentine.Composer.add_mitigation_to_assumption/2,
81+
&Valentine.Composer.remove_mitigation_from_assumption/2}
82+
83+
{:assumption, :threats} ->
84+
{&Valentine.Composer.add_threat_to_assumption/2,
85+
&Valentine.Composer.remove_threat_from_assumption/2}
86+
87+
{:mitigation, :assumptions} ->
88+
{&Valentine.Composer.add_assumption_to_mitigation/2,
89+
&Valentine.Composer.remove_assumption_from_mitigation/2}
90+
91+
{:mitigation, :threats} ->
92+
{&Valentine.Composer.add_threat_to_mitigation/2,
93+
&Valentine.Composer.remove_threat_from_mitigation/2}
94+
95+
{:threat, :assumptions} ->
96+
{&Valentine.Composer.add_assumption_to_threat/2,
97+
&Valentine.Composer.remove_assumption_from_threat/2}
98+
99+
{:threat, :mitigations} ->
100+
{&Valentine.Composer.add_mitigation_to_threat/2,
101+
&Valentine.Composer.remove_mitigation_from_threat/2}
102+
103+
{_, _} ->
104+
{nil, nil}
105+
end
106+
107+
if adder && remover do
108+
to_add
109+
|> Enum.each(fn e -> apply(adder, [entity, e]) end)
110+
111+
to_remove
112+
|> Enum.each(fn e -> apply(remover, [entity, e]) end)
113+
114+
send(self(), {__MODULE__, {:saved, entity}})
115+
end
116+
117+
{:noreply,
118+
socket
119+
|> put_flash(
120+
:info,
121+
gettext("Linked %{source_entity_type} updated",
122+
source_entity_type: Atom.to_string(source_entity_type)
123+
)
124+
)
125+
|> push_patch(to: socket.assigns.patch)}
126+
end
127+
128+
@impl true
129+
def update(%{selected_item: %{id: id}}, socket) do
130+
entity = Enum.find(socket.assigns.linkable_entities, fn t -> t.id == id end)
131+
132+
{:ok,
133+
socket
134+
|> assign(:linked_entities, [entity | socket.assigns.linked_entities])
135+
|> assign(
136+
:linkable_entities,
137+
socket.assigns.linkable_entities
138+
|> Enum.reject(fn t -> t.id == id end)
139+
)}
140+
end
141+
142+
@impl true
143+
def update(assigns, socket) do
144+
{:ok,
145+
socket
146+
|> assign(assigns)}
147+
end
148+
149+
defp entity_content(%Valentine.Composer.Threat{} = threat),
150+
do: Valentine.Composer.Threat.show_statement(threat)
151+
152+
defp entity_content(entity), do: entity.content
153+
end

valentine/lib/valentine_web/live/workspace_live/components/mitigation_component.ex

+29
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,32 @@ defmodule ValentineWeb.WorkspaceLive.Components.MitigationComponent do
3535
]}
3636
/>
3737
<div class="float-right">
38+
<.button
39+
is_icon_button
40+
aria-label="Linked assumptions"
41+
phx-click={
42+
JS.patch(
43+
~p"/workspaces/#{@mitigation.workspace_id}/mitigations/#{@mitigation.id}/assumptions"
44+
)
45+
}
46+
id={"linked-mitigation-assumptions-#{@mitigation.id}"}
47+
>
48+
<.octicon name="discussion-closed-16" />
49+
<.counter>{assoc_length(@mitigation.assumptions)}</.counter>
50+
</.button>
51+
<.button
52+
is_icon_button
53+
aria-label="Linked threats"
54+
phx-click={
55+
JS.patch(
56+
~p"/workspaces/#{@mitigation.workspace_id}/mitigations/#{@mitigation.id}/threats"
57+
)
58+
}
59+
id={"linked-mitigation-threats-#{@mitigation.id}"}
60+
>
61+
<.octicon name="squirrel-16" />
62+
<.counter>{assoc_length(@mitigation.threats)}</.counter>
63+
</.button>
3864
<.button
3965
is_icon_button
4066
aria-label="Categorize"
@@ -221,4 +247,7 @@ defmodule ValentineWeb.WorkspaceLive.Components.MitigationComponent do
221247
def handle_event("update_comments", %{"comments" => comments}, socket) do
222248
{:noreply, assign(socket, :mitigation, %{socket.assigns.mitigation | comments: comments})}
223249
end
250+
251+
defp assoc_length(l) when is_list(l), do: length(l)
252+
defp assoc_length(_), do: 0
224253
end

valentine/lib/valentine_web/live/workspace_live/mitigation/index.ex

+16-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ defmodule ValentineWeb.WorkspaceLive.Mitigation.Index do
1313
{:ok,
1414
socket
1515
|> assign(:workspace_id, workspace_id)
16+
|> assign(:workspace, workspace)
1617
|> assign(:filters, %{})
1718
|> assign(
1819
:mitigations,
@@ -25,6 +26,13 @@ defmodule ValentineWeb.WorkspaceLive.Mitigation.Index do
2526
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
2627
end
2728

29+
defp apply_action(socket, :assumptions, %{"id" => id}) do
30+
socket
31+
|> assign(:page_title, gettext("Link assumptions to mitigation"))
32+
|> assign(:assumptions, socket.assigns.workspace.assumptions)
33+
|> assign(:mitigation, Composer.get_mitigation!(id, [:assumptions]))
34+
end
35+
2836
defp apply_action(socket, :categorize, %{"id" => id}) do
2937
socket
3038
|> assign(:page_title, gettext("Categorize Mitigation"))
@@ -37,6 +45,13 @@ defmodule ValentineWeb.WorkspaceLive.Mitigation.Index do
3745
|> assign(:mitigation, Composer.get_mitigation!(id))
3846
end
3947

48+
defp apply_action(socket, :threats, %{"id" => id}) do
49+
socket
50+
|> assign(:page_title, gettext("Link threats to mitigation"))
51+
|> assign(:threats, socket.assigns.workspace.threats)
52+
|> assign(:mitigation, Composer.get_mitigation!(id, [:threats]))
53+
end
54+
4055
defp apply_action(socket, :new, _params) do
4156
socket
4257
|> assign(
@@ -117,6 +132,6 @@ defmodule ValentineWeb.WorkspaceLive.Mitigation.Index do
117132
end
118133

119134
defp get_workspace(id) do
120-
Composer.get_workspace!(id, [:mitigations])
135+
Composer.get_workspace!(id, [:assumptions, :threats, mitigations: [:assumptions, :threats]])
121136
end
122137
end

valentine/lib/valentine_web/live/workspace_live/mitigation/index.html.heex

+26
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,29 @@
7474
workspace_id={@workspace_id}
7575
patch={~p"/workspaces/#{@workspace_id}/mitigations"}
7676
/>
77+
78+
<.live_component
79+
:if={@live_action in [:assumptions]}
80+
module={ValentineWeb.WorkspaceLive.Components.EntityLinkerComponent}
81+
id={@mitigation.id}
82+
source_entity_type={:mitigation}
83+
target_entity_type={:assumptions}
84+
entity={@mitigation}
85+
linkable_entities={@assumptions}
86+
linked_entities={@mitigation.assumptions}
87+
workspace_id={@workspace_id}
88+
patch={~p"/workspaces/#{@workspace_id}/mitigations"}
89+
/>
90+
91+
<.live_component
92+
:if={@live_action in [:threats]}
93+
module={ValentineWeb.WorkspaceLive.Components.EntityLinkerComponent}
94+
id={@mitigation.id}
95+
source_entity_type={:mitigation}
96+
target_entity_type={:threats}
97+
entity={@mitigation}
98+
linkable_entities={@threats}
99+
linked_entities={@mitigation.threats}
100+
workspace_id={@workspace_id}
101+
patch={~p"/workspaces/#{@workspace_id}/mitigations"}
102+
/>

valentine/lib/valentine_web/router.ex

+8
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,18 @@ defmodule ValentineWeb.Router do
8080
live "/workspaces/:workspace_id/mitigations/new", WorkspaceLive.Mitigation.Index, :new
8181
live "/workspaces/:workspace_id/mitigations/:id/edit", WorkspaceLive.Mitigation.Index, :edit
8282

83+
live "/workspaces/:workspace_id/mitigations/:id/assumptions",
84+
WorkspaceLive.Mitigation.Index,
85+
:assumptions
86+
8387
live "/workspaces/:workspace_id/mitigations/:id/categorize",
8488
WorkspaceLive.Mitigation.Index,
8589
:categorize
8690

91+
live "/workspaces/:workspace_id/mitigations/:id/threats",
92+
WorkspaceLive.Mitigation.Index,
93+
:threats
94+
8795
live "/workspaces/:workspace_id/threats", WorkspaceLive.Threat.Index, :index
8896
live "/workspaces/:workspace_id/threats/new", WorkspaceLive.Threat.Show, :new
8997
live "/workspaces/:workspace_id/threats/:id", WorkspaceLive.Threat.Show, :edit

0 commit comments

Comments
 (0)