-
Notifications
You must be signed in to change notification settings - Fork 6
Upload data UI #234
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Upload data UI #234
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package io.hyperfoil.tools.h5m.api; | ||
|
|
||
| import io.hyperfoil.tools.jjq.value.JqValue; | ||
| import org.eclipse.microprofile.openapi.annotations.media.Schema; | ||
|
|
||
| @Schema(description = "Request body for uploading JSON data to a folder") | ||
| public record UploadRequest( | ||
| @Schema(description = "JSON data to upload (file or paste JSON)") JqValue file, | ||
| @Schema(description = "URL to fetch JSON from") String url) { | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,12 @@ | ||
| package io.hyperfoil.tools.h5m.rest; | ||
|
|
||
| import io.hyperfoil.tools.jjq.value.JqValue; | ||
| import io.hyperfoil.tools.jjq.value.JqValues; | ||
| import jakarta.json.Json; | ||
| import jakarta.json.JsonException; | ||
| import io.hyperfoil.tools.h5m.api.Folder; | ||
| import io.hyperfoil.tools.h5m.api.FolderSummary; | ||
| import io.hyperfoil.tools.h5m.api.UploadRequest; | ||
| import io.hyperfoil.tools.h5m.api.RecalculationStatus; | ||
| import io.hyperfoil.tools.h5m.api.svc.FolderServiceInterface; | ||
| import io.hyperfoil.tools.h5m.api.svc.ValueServiceInterface; | ||
|
|
@@ -13,11 +17,16 @@ | |
| import jakarta.inject.Inject; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.ws.rs.*; | ||
| import jakarta.ws.rs.core.Response; | ||
| import jakarta.ws.rs.core.MediaType; | ||
| import org.eclipse.microprofile.openapi.annotations.Operation; | ||
| import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; | ||
| import org.eclipse.microprofile.openapi.annotations.tags.Tag; | ||
|
|
||
| import java.io.IOException; | ||
| import java.io.StringReader; | ||
| import java.net.URI; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
|
|
@@ -87,9 +96,29 @@ public void deleteFolder(@PathParam("id") long id) { | |
| @Operation(description = "Upload JSON data to a folder. Returns immediately with an uploadId.") | ||
| public long upload( | ||
| @PathParam("id") long id, | ||
| JqValue data) { | ||
| UploadRequest body) { | ||
| String url = body != null ? body.url() : null; | ||
| JqValue data = null; | ||
| if (url != null && !url.isEmpty()) { | ||
| try (var inputStream = URI.create(url).toURL().openStream()) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for the URL parameter, verify that it's schema is HTTP / HTTPS to prevent some SSRF attacks that look into the local filesystem (like file://, jar://) and other protocols like ftp:// |
||
| String raw = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); | ||
| try (var reader = Json.createReader(new StringReader(raw))) { | ||
| reader.readValue(); | ||
| } catch (JsonException e) { | ||
| throw new WebApplicationException(Response.status(400).entity("Invalid JSON from URL '" + url + "': " + e.getMessage()).type(MediaType.TEXT_PLAIN).build()); | ||
| } | ||
| data = JqValues.parse(raw); | ||
| } catch (IOException e) { | ||
| throw new WebApplicationException(Response.status(400).entity("Failed to fetch URL '" + url + "': " + e.getMessage()).type(MediaType.TEXT_PLAIN).build()); | ||
| } catch (Exception e) { | ||
| throw new WebApplicationException(Response.status(400).entity("Invalid JSON from URL '" + url + "': " + e.getMessage()).type(MediaType.TEXT_PLAIN).build()); | ||
| } | ||
| } else if (body != null && body.file() != null) { | ||
| data = body.file(); | ||
| } | ||
|
|
||
| if (data == null) { | ||
| throw new BadRequestException("Missing request body"); | ||
| throw new BadRequestException("Provide either 'file', raw data, (JSON data) or 'url' in the request body"); | ||
| } | ||
| return folderService.upload(id, data).uploadId; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import type { View, ViewComponent } from '@client/types.gen.ts'; | ||
|
|
||
| import { UploadDataModal } from '@app/components/UploadDataModal'; | ||
| import { ViewConfigModal } from '@app/components/ViewConfigModal'; | ||
| import { | ||
| Button, | ||
|
|
@@ -8,6 +9,11 @@ import { | |
| ErrorBoundary, | ||
| InlineLoading, | ||
| SkeletonText, | ||
| StructuredListBody, | ||
| StructuredListCell, | ||
| StructuredListHead, | ||
| StructuredListRow, | ||
| StructuredListWrapper, | ||
| Table, | ||
| TableBody, | ||
| TableCell, | ||
|
|
@@ -91,6 +97,8 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb | |
| const [configModalOpen, setConfigModalOpen] = useState(false); | ||
| const [editingView, setEditingView] = useState<View | null>(null); | ||
| const [modalKey, setModalKey] = useState(0); | ||
| const [uploadModalOpen, setUploadModalOpen] = useState(false); | ||
| const [recentUploads, setRecentUploads] = useState<Array<{ fileName: string; uploadId: number }>>([]); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could also track the upload time |
||
|
|
||
| const selectedView = useMemo((): View | null => { | ||
| if (!views || views.length === 0) return null; | ||
|
|
@@ -107,7 +115,61 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb | |
| }, [views, selectedViewId]); | ||
|
|
||
| if (viewsLoading) return <SkeletonText paragraph={true} lineCount={3} />; | ||
| if (!views || views.length === 0) return <p>No views configured</p>; | ||
|
|
||
| const uploadButton = ( | ||
| <Button kind="primary" size="md" onClick={() => setUploadModalOpen(true)}> | ||
| Upload data | ||
| </Button> | ||
| ); | ||
|
|
||
| const uploadModal = ( | ||
| <ErrorBoundary fallback={<InlineLoading status="error" description="Failed to load modal" />}> | ||
| <Suspense fallback={null}> | ||
| <UploadDataModal | ||
| open={uploadModalOpen} | ||
| onClose={() => setUploadModalOpen(false)} | ||
| folderId={folderId} | ||
| onUploadSuccess={(fileName, uploadId) => { | ||
| setRecentUploads((prev) => [{ fileName, uploadId }, ...prev]); | ||
| }} | ||
| /> | ||
| </Suspense> | ||
| </ErrorBoundary> | ||
| ); | ||
|
|
||
| const recentUploadsList = recentUploads.length > 0 ? ( | ||
| <div style={{ marginBottom: 'var(--cds-spacing-05)' }}> | ||
| <StructuredListWrapper> | ||
| <StructuredListHead> | ||
| <StructuredListRow head> | ||
| <StructuredListCell head>Upload file</StructuredListCell> | ||
| <StructuredListCell head>Upload ID</StructuredListCell> | ||
| </StructuredListRow> | ||
| </StructuredListHead> | ||
| <StructuredListBody> | ||
| {recentUploads.map((item, i) => ( | ||
| <StructuredListRow key={`${item.uploadId}-${String(i)}`}> | ||
| <StructuredListCell>{item.fileName}</StructuredListCell> | ||
| <StructuredListCell> | ||
| <span style={{ fontWeight: 600, color: 'var(--cds-support-success)' }}> | ||
| #{item.uploadId} | ||
| </span> | ||
| </StructuredListCell> | ||
| </StructuredListRow> | ||
| ))} | ||
| </StructuredListBody> | ||
| </StructuredListWrapper> | ||
| </div> | ||
| ) : null; | ||
|
|
||
| if (!views || views.length === 0) return ( | ||
| <> | ||
| <div style={{ marginBottom: 'var(--cds-spacing-05)' }}>{uploadButton}</div> | ||
| {recentUploadsList} | ||
| <p>No views configured</p> | ||
| {uploadModal} | ||
| </> | ||
| ); | ||
|
|
||
| const dropdownItems = views.map((v: View) => ({ | ||
| id: String(v.id), | ||
|
|
@@ -117,6 +179,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb | |
| return ( | ||
| <div> | ||
| <div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--cds-spacing-03)', marginBottom: 'var(--cds-spacing-05)' }}> | ||
| {uploadButton} | ||
| <div style={{ maxWidth: '300px', flex: 1 }}> | ||
| <Dropdown | ||
| id="view-selector" | ||
|
|
@@ -150,6 +213,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb | |
| New View | ||
| </Button> | ||
| </div> | ||
| {recentUploadsList} | ||
| {selectedView && (!selectedView.components || selectedView.components.length === 0) && ( | ||
| <p style={{ opacity: 0.7 }}> | ||
| This view has no columns configured. Click <strong>Configure</strong> to select which nodes to display. | ||
|
|
@@ -174,6 +238,7 @@ export const DataTab = ({ folderId, groupId }: { folderId: number; groupId: numb | |
| /> | ||
| </Suspense> | ||
| </ErrorBoundary> | ||
| {uploadModal} | ||
| </div> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this object is not needed. I would rather see the different types uploads to be sent as
MULTIPART_FORM_DATA(openAPI generators will create some kind of request object from it)this would be what the endpoint would look like:
Tests will require
io.restassuredmultipart builders instead of simple.body(json).