Skip to content

Commit ffc914a

Browse files
feat: Add file delete UI and refine upload naming (#1406)
- Added delete functionality to the UI, allowing users to remove previously uploaded files - Fix backend to properly handle file deletions for all files - Expand and improve the file naming logic - Minor optimizations and code cleanups in the file handling components Signed-off-by: Omar <omar.brbutovic@secomind.com>
1 parent fd4da1d commit ffc914a

6 files changed

Lines changed: 275 additions & 93 deletions

File tree

backend/lib/edgehog/files/uploaders/file.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,6 @@ defmodule Edgehog.Files.Uploaders.File do
5757
end
5858

5959
def filename(_version, {_file, scope}) do
60-
scope.file_name
60+
Path.rootname(scope.file_name)
6161
end
6262
end

frontend/src/components/FilesTable.tsx

Lines changed: 140 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,20 @@
1717
// SPDX-License-Identifier: Apache-2.0
1818

1919
import _ from "lodash";
20-
import { useMemo } from "react";
20+
import { useCallback, useMemo, useState } from "react";
2121
import { FormattedMessage } from "react-intl";
2222
import { graphql, useFragment } from "react-relay/hooks";
23+
import { useMutation } from "react-relay";
2324

2425
import type {
2526
FilesTable_FileEdgeFragment$data,
2627
FilesTable_FileEdgeFragment$key,
2728
} from "@/api/__generated__/FilesTable_FileEdgeFragment.graphql";
2829

30+
import type { FilesTable_deleteFile_Mutation } from "@/api/__generated__/FilesTable_deleteFile_Mutation.graphql";
31+
2932
import Button from "@/components/Button";
33+
import DeleteModal from "@/components/DeleteModal";
3034
import Icon from "@/components/Icon";
3135
import InfiniteTable from "@/components/InfiniteTable";
3236
import { createColumnHelper } from "@/components/Table";
@@ -49,12 +53,60 @@ const FILES_TABLE_FRAGMENT = graphql`
4953
}
5054
`;
5155

56+
const DELETE_FILE_MUTATION = graphql`
57+
mutation FilesTable_deleteFile_Mutation($fileId: ID!) {
58+
deleteFile(id: $fileId) {
59+
result {
60+
id
61+
}
62+
}
63+
}
64+
`;
65+
66+
type FileActionsProps = {
67+
file: TableRecord;
68+
onDeleteClick: (file: TableRecord) => void;
69+
};
70+
71+
const FileActions = ({ file, onDeleteClick }: FileActionsProps) => {
72+
const handleDownload = () => {
73+
const url = file.baseFile.url;
74+
if (!url) return;
75+
76+
const a = document.createElement("a");
77+
a.href = url;
78+
a.download = file.name || "file";
79+
a.target = "_blank";
80+
document.body.appendChild(a);
81+
a.click();
82+
a.remove();
83+
};
84+
85+
return (
86+
<div className="d-inline-flex align-items-center gap-2">
87+
<Button
88+
className="btn p-0 border-0 bg-transparent"
89+
onClick={handleDownload}
90+
>
91+
<Icon className="text-primary" icon="arrowDown" />
92+
</Button>
93+
94+
<Button
95+
className="btn p-0 border-0 bg-transparent"
96+
onClick={() => onDeleteClick(file)}
97+
>
98+
<Icon className="text-danger" icon="delete" />
99+
</Button>
100+
</div>
101+
);
102+
};
103+
52104
type TableRecord = NonNullable<
53105
NonNullable<FilesTable_FileEdgeFragment$data>["edges"]
54106
>[number]["node"];
55107

56108
const columnHelper = createColumnHelper<TableRecord>();
57-
const getColumnsDefinition = () => [
109+
const getColumnsDefinition = (onDeleteClick: (file: TableRecord) => void) => [
58110
columnHelper.accessor("name", {
59111
header: () => (
60112
<FormattedMessage
@@ -75,37 +127,22 @@ const getColumnsDefinition = () => [
75127
),
76128
cell: ({ getValue }) => {
77129
const size = getValue();
78-
if (size == null) return null;
79-
return formatFileSize(size);
130+
return size != null ? formatFileSize(size) : null;
80131
},
81132
}),
82133
columnHelper.accessor((row) => row, {
83134
id: "action",
84135
header: () => (
85136
<FormattedMessage
86-
id="components.FilesTable.action"
87-
defaultMessage="Action"
137+
id="components.FilesTable.actionsTitle"
138+
defaultMessage="Actions"
88139
/>
89140
),
90-
cell: ({ row }) => (
91-
<Button
92-
className="btn p-0 border-0 bg-transparent ms-4"
93-
onClick={() => {
94-
const url = row.original.baseFile.url;
95-
if (!url) return;
96-
97-
const a = document.createElement("a");
98-
a.href = url;
99-
a.download = row.original.name || "file";
100-
a.target = "_blank";
101-
document.body.appendChild(a);
102-
a.click();
103-
a.remove();
104-
}}
105-
>
106-
<Icon className="text-primary" icon={"arrowDown"} />
107-
</Button>
108-
),
141+
cell: ({ row }) => {
142+
const file = row.original;
143+
144+
return <FileActions file={file} onDeleteClick={onDeleteClick} />;
145+
},
109146
}),
110147
];
111148

@@ -127,17 +164,86 @@ const FilesTable = ({
127164
return _.compact(filesFragment?.edges?.map((e) => e?.node)) ?? [];
128165
}, [filesFragment]);
129166

130-
const columns = useMemo(() => getColumnsDefinition(), []);
167+
const [fileToDelete, setFileToDelete] = useState<TableRecord | null>(null);
168+
const [errorFeedback, setErrorFeedback] = useState<React.ReactNode>(null);
169+
170+
const columns = useMemo(() => getColumnsDefinition(setFileToDelete), []);
171+
172+
const handleCancelDelete = useCallback(() => {
173+
setFileToDelete(null);
174+
setErrorFeedback(null);
175+
}, []);
176+
177+
const [deleteFile, isDeletingFile] =
178+
useMutation<FilesTable_deleteFile_Mutation>(DELETE_FILE_MUTATION);
179+
180+
const handleDeleteFile = useCallback(() => {
181+
if (!fileToDelete) return;
182+
183+
deleteFile({
184+
variables: { fileId: fileToDelete.id },
185+
onCompleted(_data, errors) {
186+
if (errors) {
187+
const errorMessages = errors
188+
.map((error) => error.message)
189+
.join(". \n");
190+
setErrorFeedback(errorMessages);
191+
return;
192+
}
193+
194+
setErrorFeedback(null);
195+
setFileToDelete(null);
196+
},
197+
onError() {
198+
setErrorFeedback(
199+
<FormattedMessage
200+
id="components.FilesTable.deletionErrorFeedback"
201+
defaultMessage="Could not delete the file, please try again."
202+
/>,
203+
);
204+
},
205+
updater(store, response) {
206+
const deletedId = response?.deleteFile?.result?.id;
207+
if (!deletedId) return;
208+
209+
store.delete(deletedId);
210+
},
211+
});
212+
}, [deleteFile, fileToDelete]);
131213

132214
return (
133-
<InfiniteTable
134-
className={className}
135-
columns={columns}
136-
data={files}
137-
loading={loading}
138-
onLoadMore={onLoadMore}
139-
hideSearch
140-
/>
215+
<>
216+
<InfiniteTable
217+
className={className}
218+
columns={columns}
219+
data={files}
220+
loading={loading}
221+
onLoadMore={onLoadMore}
222+
hideSearch
223+
/>
224+
{fileToDelete && (
225+
<DeleteModal
226+
confirmText={fileToDelete.name || ""}
227+
onCancel={handleCancelDelete}
228+
onConfirm={handleDeleteFile}
229+
isDeleting={isDeletingFile}
230+
title={
231+
<FormattedMessage
232+
id="components.FilesTable.deleteModal.title"
233+
defaultMessage="Delete File"
234+
/>
235+
}
236+
>
237+
<p>
238+
<FormattedMessage
239+
id="components.FilesTable.deleteModal.description"
240+
defaultMessage="This action cannot be undone. This will permanently delete the file."
241+
/>
242+
</p>
243+
{errorFeedback && <p className="text-danger">{errorFeedback}</p>}
244+
</DeleteModal>
245+
)}
246+
</>
141247
);
142248
};
143249

0 commit comments

Comments
 (0)