Skip to content

Commit 4d77403

Browse files
authored
Merge pull request #374 from jrowah/delete-uploaded-image
Delete uploaded images feature
2 parents cef6873 + 5c9cfc2 commit 4d77403

File tree

13 files changed

+253
-17
lines changed

13 files changed

+253
-17
lines changed

.cla/contributors.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
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

assets/css/styles.scss

+2-2
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ html {
6767
}
6868

6969
&.button--small {
70-
@apply py-1 px-1 text-sm;
70+
@apply py-1 px-1 text-sm min-h-7;
7171

7272
svg {
7373
@apply w-4;
@@ -350,4 +350,4 @@ Hover helper to bridge the gap between the Plus button and the toolbox
350350

351351
.codex-editor .codex-editor--toolbox-opened .ce-toolbox:active:after {
352352
pointer-events: none;
353-
}
353+
}

assets/js/app.js

+2
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import { CampaignSettingsDialogHook } from "./hooks/campaign-settings-dialog"
1919
import CampaignStatsChartHook from "./hooks/campaign-stats-chart"
2020
import * as ContactsTable from "./hooks/contacts-table"
2121
import * as DateTimeHooks from "./hooks/date-time"
22+
import { FileManager } from "./hooks/file_manager"
2223
import { RememberUnsaved } from "./hooks/remember-unsaved"
2324

2425
const Hooks = {
2526
...DateTimeHooks,
2627
...CampaignEditLiveHooks,
2728
...ContactsTable,
29+
FileManager,
2830
RememberUnsaved,
2931
CampaignSettingsDialogHook,
3032
CampaignStatsChartHook

assets/js/campaign-editors/block/blocks/image/index.js

+5-4
Original file line numberDiff line numberDiff line change
@@ -106,13 +106,14 @@ export default class Image {
106106
}
107107

108108
openDialog() {
109-
document.querySelector("[data-dialog-for=image]").dispatchEvent(
110-
new CustomEvent("x-show", { detail: this.data.image })
111-
)
109+
document
110+
.querySelector("[data-dialog-for=image]")
111+
.dispatchEvent(new CustomEvent("x-show", { detail: this.data.image }))
112+
112113
window.addEventListener("update-image", e => {
113114
const { src, alt, title, id } = e.detail
114115
const image = { src, alt, title, id }
115-
if (!e.detail.cancel && image.src) {
116+
if (!e.detail.cancel) {
116117
this.data.image = image
117118
this.drawView()
118119
}

assets/js/campaign-editors/markdown/menu.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,18 @@ export function buildDefaultMenu() {
106106
detail.tab = "url"
107107
}
108108

109-
document.querySelector("[data-dialog-for=image]").dispatchEvent(new CustomEvent("x-show", { detail }))
109+
document
110+
.querySelector("[data-dialog-for=image]")
111+
.dispatchEvent(new CustomEvent("x-show", { detail }))
112+
110113
window.addEventListener("update-image", e => {
111114
const image = e.detail
112115
if (!image.cancel && image.src) {
113-
editorView.dispatch(editorView.state.tr.replaceSelectionWith(schema.nodes.image.createAndFill(image)))
116+
editorView.dispatch(
117+
editorView.state.tr.replaceSelectionWith(schema.nodes.image.createAndFill(image))
118+
)
119+
} else if (!image.cancel && !image.src) {
120+
editorView.dispatch(editorView.state.tr.deleteSelection())
114121
}
115122
editorView.focus()
116123
}, { once: true })

assets/js/hooks/file_manager.js

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const FileManager = {
2+
mounted() {
3+
this.handleEvent("remove_file", ({ id, src }) => {
4+
this.el.dispatchEvent(
5+
new CustomEvent("x-file-removed", {
6+
detail: { id, src },
7+
bubbles: true
8+
})
9+
)
10+
})
11+
12+
this.handleEvent("file_in_use", ({ campaigns }) => {
13+
this.el.dispatchEvent(
14+
new CustomEvent("x-file-in-use", {
15+
detail: { campaigns },
16+
bubbles: true
17+
})
18+
)
19+
})
20+
}
21+
}
22+
23+
export { FileManager }

lib/keila/files/files.ex

+22-3
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,24 @@ defmodule Keila.Files do
109109
end
110110
end
111111

112+
@doc """
113+
Retrieves the file specified by its UUID from a given project.
114+
115+
Returns `nil` if file doesn’t exist.
116+
"""
117+
@spec get_project_file(Project.id(), File.id()) :: File.t() | nil
118+
def get_project_file(project_id, file_id)
119+
when is_binary(project_id) or is_integer(project_id) do
120+
query =
121+
from(f in File,
122+
where: f.project_id == ^project_id,
123+
where: f.uuid == ^file_id,
124+
preload: [:project]
125+
)
126+
127+
Repo.one(query)
128+
end
129+
112130
@doc """
113131
Returns all Files belonging to specified Project.
114132
@@ -139,11 +157,12 @@ defmodule Keila.Files do
139157
def delete_file(uuid) do
140158
with file = %File{} <- get_file(uuid),
141159
adapter <- get_adapter(file.adapter),
142-
:ok <- adapter.delete(file) do
143-
Repo.delete_all(from(f in File, where: f.uuid == ^uuid))
160+
:ok <- adapter.delete(file),
161+
{:ok, _file} <- Repo.delete(file) do
144162
:ok
145163
else
146-
_ -> :ok
164+
nil -> :ok
165+
{:error, changeset} -> {:error, changeset}
147166
end
148167
end
149168

lib/keila/mailings/mailings.ex

+25
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,31 @@ defmodule Keila.Mailings do
386386
end
387387
end
388388

389+
@doc """
390+
Searches for campaigns in a given project that contain the given search string.
391+
392+
Returns a list of campaigns that match the search string or an empty list if no campaigns match.
393+
394+
The search string is matched against the `text_body`, `html_body`, `mjml_body`, and `json_body` fields.
395+
"""
396+
@spec search_in_project_campaigns(Project.id(), String.t()) :: [Campaign.t()]
397+
def search_in_project_campaigns(project_id, search_string)
398+
when is_binary(project_id) or is_integer(project_id) do
399+
from(c in Campaign,
400+
where: c.project_id == ^project_id,
401+
where:
402+
fragment(
403+
"text_body LIKE ? OR html_body LIKE ? OR mjml_body LIKE ? OR json_body::text LIKE ?",
404+
^"%#{search_string}%",
405+
^"%#{search_string}%",
406+
^"%#{search_string}%",
407+
^"%#{search_string}%"
408+
),
409+
order_by: [desc: :updated_at]
410+
)
411+
|> Repo.all()
412+
end
413+
389414
defp get_and_lock_campaign(id) when is_id(id) do
390415
from(c in Campaign, where: c.id == ^id, lock: "FOR NO KEY UPDATE", preload: :segment)
391416
|> Repo.one()

lib/keila_web/components/file_manager_live.ex

+44
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,50 @@ defmodule KeilaWeb.FileManagerLiveComponent do
3939
{:noreply, socket |> put_files()}
4040
end
4141

42+
def handle_event("delete_upload", %{"id" => file_uuid}, socket) do
43+
project_id = socket.assigns.current_project_id
44+
campaign_id = socket.assigns[:current_campaign_id]
45+
46+
case Keila.Files.get_project_file(project_id, file_uuid) do
47+
nil ->
48+
{:noreply, socket}
49+
50+
file ->
51+
file_url = Keila.Files.get_file_url(file.uuid)
52+
53+
campaigns =
54+
Keila.Mailings.search_in_project_campaigns(project_id, file_url)
55+
|> Enum.filter(fn campaign -> is_nil(campaign_id) or campaign.id != campaign_id end)
56+
57+
case campaigns do
58+
[] ->
59+
case Keila.Files.delete_file(file.uuid) do
60+
:ok ->
61+
{:noreply,
62+
socket
63+
|> push_event("remove_file", %{id: file_uuid, src: file_url})
64+
|> put_files()}
65+
66+
{:error, _} ->
67+
{:noreply, socket}
68+
end
69+
70+
campaigns ->
71+
campaign_details =
72+
Enum.map(campaigns, fn campaign ->
73+
%{
74+
id: campaign.id,
75+
subject: campaign.subject,
76+
status: if(campaign.sent_at, do: "sent", else: "draft")
77+
}
78+
end)
79+
80+
{:noreply,
81+
push_event(socket, "file_in_use", %{campaigns: campaign_details, id: file_uuid})}
82+
end
83+
end
84+
end
85+
4286
def handle_event("change-page", %{"page" => page}, socket) do
4387
page = String.to_integer(page)
4488

lib/keila_web/templates/campaign/_wysiwyg_dialogs.html.heex

+3-1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
x-on:x-confirm="$dispatch('update-image', image)"
6969
x-on:x-cancel="$dispatch('update-image', { cancel: true} )"
7070
x-on:x-file-selected.stop="image.src = $event.detail.url; imageTab = 'url'"
71+
x-on:x-file-removed.stop="if (image.src == $event.detail.src) image = {}"
7172
@keydown.enter.prevent="$dispatch('x-confirm')"
7273
@keydown.esc.prevent="$dispatch('x-cancel')"
7374
@click.away="$dispatch('x-cancel')"
@@ -94,8 +95,9 @@
9495
<div class="tab-content" data-tab="uploads" x-show="imageTab === 'uploads'">
9596
<.live_component
9697
module={KeilaWeb.FileManagerLiveComponent}
97-
id="foo"
98+
id="file-manager"
9899
current_project_id={@current_project.id}
100+
current_campaign_id={@campaign.id}
99101
/>
100102
</div>
101103
<div class="tab-content" data-tab="url" x-show="imageTab === 'url'">

lib/keila_web/templates/component/file_manager_live.html.heex

+61-4
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,37 @@
4848
</div>
4949
</div>
5050
<% else %>
51-
<div class="overflow-y-scroll max-h-96 grid grid-gap-4 grid-cols-2">
51+
<div
52+
id="file-container"
53+
phx-hook="FileManager"
54+
class="overflow-y-scroll overflow-x-visible max-h-96 grid gap-4 grid-cols-2"
55+
>
5256
<%= for {file, url} <- Enum.zip(@files.data, @file_urls) do %>
5357
<div
54-
class="max-w-[10rem]"
58+
id={"file-container-#{file.uuid}"}
59+
class="px-2"
5560
phx-click={JS.dispatch("x-file-selected", detail: %{url: url, id: file.uuid})}
61+
x-data="{ showDelete: false, showUsageWarning: false, campaigns: [] }"
5662
>
57-
<img id={"file-#{file.uuid}"} src={url} alt="" class="h-3/4 w-full object-cover" />
58-
<p class="text-xs"><%= file.filename %></p>
63+
<img id={"file-#{file.uuid}"} src={url} alt="" class="h-32 w-full object-cover" />
64+
<div class="flex items-center justify-between text-xs mt-1 relative">
65+
<p><%= file.filename %></p>
66+
<button @click.stop="showDelete = true" class="button button--text button--small">
67+
<%= render_icon(:trash) %>
68+
<span class="sr-only">title={gettext("Delete")}</span>
69+
</button>
70+
<button
71+
x-show="showDelete"
72+
@click.away.prevent.stop="showDelete = false"
73+
phx-click="delete_upload"
74+
phx-value-id={file.uuid}
75+
phx-value-campaign-id={}
76+
phx-target={@myself}
77+
class="button button--small button--warn l-0 w-full absolute"
78+
>
79+
<%= gettext("Click to delete") %>
80+
</button>
81+
</div>
5982
</div>
6083
<% end %>
6184
</div>
@@ -64,4 +87,38 @@
6487
<%= pagination_nav(@files, phx_click: "change-page", phx_target: @myself) %>
6588
</div>
6689
<% end %>
90+
91+
<div
92+
x-data="{show: false, id: null, campaigns: []}"
93+
x-show="show"
94+
class="fixed inset-0 bg-black/90 flex items-center justify-center"
95+
@x-file-in-use.window="show = true; campaigns = $event.detail.campaigns"
96+
>
97+
<div class="bg-gray-900 p-4 rounded-lg max-w-lg" @click.away="show = false; id = null">
98+
<div x-ref="i18n" data-draft={gettext("draft")} data-sent={gettext("sent")}></div>
99+
<h3 class="text-lg font-bold mb-4">
100+
<%= gettext(
101+
"This file can’t be deleted because it’s already in use in the following campaigns:"
102+
) %>
103+
</h3>
104+
<template x-for="campaign in campaigns">
105+
<div class="flex justify-between items-center mb-2">
106+
<span x-text="campaign.subject"></span>
107+
<span
108+
x-text="$refs.i18n.dataset[campaign.status]"
109+
:class="{
110+
'text-green-700': campaign.status === 'draft',
111+
'text-red-600': campaign.status === 'sent'
112+
}"
113+
>
114+
</span>
115+
</div>
116+
</template>
117+
<div class="flex justify-end gap-8 mt-4">
118+
<button @click="id = null; show = false" class="button button--cta">
119+
<%= gettext("Ok") %>
120+
</button>
121+
</div>
122+
</div>
123+
</div>
67124
</div>

test/keila/files/files_test.exs

+45
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ defmodule Keila.FilesTest do
22
use Keila.DataCase, async: false
33
use Keila.FileCase
44

5+
alias Keila.Mailings
6+
57
@test_file "test/keila/files/keila.png"
68
@test_file_jpg "test/keila/files/keila.jpg"
79

@@ -31,6 +33,41 @@ defmodule Keila.FilesTest do
3133
assert nil == Files.get_file(file.uuid)
3234
end
3335

36+
@tag :files
37+
test "detect file usage in campaigns" do
38+
project = insert!(:project)
39+
40+
# Store a file
41+
{:ok, file} =
42+
Files.store_file(project.id, @test_file, filename: "keila.png", type: "image/png")
43+
44+
file_url = Files.get_file_url(file.uuid)
45+
46+
campaign_with_uuid =
47+
insert!(:mailings_campaign,
48+
project_id: project.id,
49+
json_body: %{"blocks" => [%{"type" => "image", "data" => %{"src" => file.uuid}}]}
50+
)
51+
52+
campaign_with_url =
53+
insert!(:mailings_campaign,
54+
project_id: project.id,
55+
html_body: "<img src=\"#{file_url}\">"
56+
)
57+
58+
uuid_results = Mailings.search_in_project_campaigns(project.id, file.uuid)
59+
assert length(uuid_results) == 2
60+
assert campaign_with_uuid.id in Enum.map(uuid_results, & &1.id)
61+
62+
url_results = Mailings.search_in_project_campaigns(project.id, file_url)
63+
assert length(url_results) == 1
64+
assert campaign_with_url.id in Enum.map(url_results, & &1.id)
65+
66+
other_project = insert!(:project)
67+
other_results = Mailings.search_in_project_campaigns(other_project.id, file.uuid)
68+
assert Enum.empty?(other_results)
69+
end
70+
3471
@tag :files
3572
test "Get project files" do
3673
project = insert!(:project)
@@ -47,6 +84,14 @@ defmodule Keila.FilesTest do
4784
assert [] == Files.get_project_files(project2.id, paginate: false)
4885
end
4986

87+
@tag :files
88+
test "Get project file" do
89+
project = insert!(:project)
90+
file = insert!(:file, project: project)
91+
92+
assert Files.get_project_file(project.id, file.uuid) == file
93+
end
94+
5095
@tag :files
5196
test "Media type and extension match check" do
5297
project = insert!(:project)

0 commit comments

Comments
 (0)