From 7c08eefb3c9db7ac0ade0f260b79c462df812abd Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Fri, 25 Apr 2025 20:23:16 -0600 Subject: [PATCH 01/25] Basic file uploading Signed-off-by: Mikayla Thompson --- .../playground/InputDocumentSection.tsx | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/playground/InputDocumentSection.tsx b/frontend/src/components/playground/InputDocumentSection.tsx index 5c7d171c6f..972963c593 100644 --- a/frontend/src/components/playground/InputDocumentSection.tsx +++ b/frontend/src/components/playground/InputDocumentSection.tsx @@ -4,6 +4,9 @@ import Header from "@cloudscape-design/components/header"; import Button from "@cloudscape-design/components/button"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Box from "@cloudscape-design/components/box"; +import FileUpload from "@cloudscape-design/components/file-upload"; +import FormField from "@cloudscape-design/components/form-field"; + import { usePlayground } from "../../context/PlaygroundContext"; import { usePlaygroundActions } from "../../hooks/usePlaygroundActions"; @@ -11,10 +14,77 @@ export default function InputDocumentSection() { const { state } = usePlayground(); const { addInputDocument } = usePlaygroundActions(); + const [uploadFileList, setUploadFileList] = React.useState([]); + const handleAddDocument = () => { addInputDocument(`Document ${state.inputDocuments.length + 1}`, ""); }; + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (uploadFileList.length === 0) return; + + try { + for (const file of uploadFileList) { + // Read file content + const content = await readFileAsText(file); + + // Validate JSON or newline-delimited JSON + validateJsonContent(content); + + // Add as new input document + addInputDocument(file.name, content); + } + + // Clear the upload file list on success + setUploadFileList([]); + } catch (error) { + console.error("Error processing files:", error); + // Could add error handling UI here + } + }; + + // Helper function to read file content + const readFileAsText = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => + reject(new Error(`Failed to read file: ${file.name}`)); + reader.readAsText(file); + }); + }; + + // Helper function to validate JSON content + const validateJsonContent = (content: string): void => { + try { + // Try parsing as regular JSON first + JSON.parse(content); + } catch (e) { + // If not regular JSON, check if it's newline-delimited JSON + const lines = content.trim().split("\n"); + let isValid = false; + + for (const line of lines) { + if (line.trim()) { + try { + JSON.parse(line); // Will throw if invalid + isValid = true; + } catch (lineError: unknown) { + const errorMessage = + lineError instanceof Error ? lineError.message : "Unknown error"; + throw new Error(`Invalid JSON format in file: ${errorMessage}`); + } + } + } + + if (!isValid) { + throw new Error("File contains no valid JSON data"); + } + } + }; + return ( Input Documents}> @@ -23,7 +93,29 @@ export default function InputDocumentSection() { ) : ( state.inputDocuments.map((doc) => {doc.name}) )} - +
+ + { + setUploadFileList(detail.value); + }} + value={uploadFileList} + multiple + accept="application/json" + i18nStrings={{ + uploadButtonText: (e) => (e ? "Choose files" : "Choose file"), + dropzoneText: (e) => + e ? "Drop files to upload" : "Drop file to upload", + removeFileAriaLabel: (e) => `Remove file ${e + 1}`, + }} + showFileLastModified + showFileSize + tokenLimit={3} + constraintText="JSON input documents" + /> + + +
); From f518b713e7bb3a15ea925a27d31a789c0aa3570f Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Fri, 25 Apr 2025 20:47:58 -0600 Subject: [PATCH 02/25] Improve error checking/display Signed-off-by: Mikayla Thompson --- .../playground/InputDocumentSection.tsx | 80 ++++++++++--------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/playground/InputDocumentSection.tsx b/frontend/src/components/playground/InputDocumentSection.tsx index 972963c593..ea78748caf 100644 --- a/frontend/src/components/playground/InputDocumentSection.tsx +++ b/frontend/src/components/playground/InputDocumentSection.tsx @@ -15,33 +15,33 @@ export default function InputDocumentSection() { const { addInputDocument } = usePlaygroundActions(); const [uploadFileList, setUploadFileList] = React.useState([]); - - const handleAddDocument = () => { - addInputDocument(`Document ${state.inputDocuments.length + 1}`, ""); - }; + const [fileErrors, setFileErrors] = React.useState<(string | null)[]>([]); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (uploadFileList.length === 0) return; + setFileErrors([]); try { - for (const file of uploadFileList) { + for (const [i, file] of uploadFileList.entries()) { // Read file content const content = await readFileAsText(file); // Validate JSON or newline-delimited JSON - validateJsonContent(content); + const potentialError = validateJsonContent(content); + if (potentialError) { + setFileErrors([...fileErrors, potentialError]); + continue; // Skip this file if there's an error + } // Add as new input document addInputDocument(file.name, content); + // Remove the file from the upload list + setUploadFileList((prev) => prev.filter((_, index) => index !== i)); } - - // Clear the upload file list on success - setUploadFileList([]); } catch (error) { console.error("Error processing files:", error); - // Could add error handling UI here } }; @@ -57,31 +57,28 @@ export default function InputDocumentSection() { }; // Helper function to validate JSON content - const validateJsonContent = (content: string): void => { + const validateJsonContent = (content: string): string | null => { try { // Try parsing as regular JSON first JSON.parse(content); + return null; } catch (e) { // If not regular JSON, check if it's newline-delimited JSON const lines = content.trim().split("\n"); - let isValid = false; for (const line of lines) { if (line.trim()) { try { JSON.parse(line); // Will throw if invalid - isValid = true; + return null; // Valid JSON found } catch (lineError: unknown) { const errorMessage = lineError instanceof Error ? lineError.message : "Unknown error"; - throw new Error(`Invalid JSON format in file: ${errorMessage}`); + return `Invalid JSON format in file: ${errorMessage}`; } } } - - if (!isValid) { - throw new Error("File contains no valid JSON data"); - } + return "File is not valid JSON data"; } }; @@ -94,27 +91,32 @@ export default function InputDocumentSection() { state.inputDocuments.map((doc) => {doc.name}) )}
- - { - setUploadFileList(detail.value); - }} - value={uploadFileList} - multiple - accept="application/json" - i18nStrings={{ - uploadButtonText: (e) => (e ? "Choose files" : "Choose file"), - dropzoneText: (e) => - e ? "Drop files to upload" : "Drop file to upload", - removeFileAriaLabel: (e) => `Remove file ${e + 1}`, - }} - showFileLastModified - showFileSize - tokenLimit={3} - constraintText="JSON input documents" - /> - - + + + { + setUploadFileList(detail.value); + }} + value={uploadFileList} + multiple + accept="application/json" + i18nStrings={{ + uploadButtonText: (e) => (e ? "Choose files" : "Choose file"), + dropzoneText: (e) => + e ? "Drop files to upload" : "Drop file to upload", + removeFileAriaLabel: (e) => `Remove file ${e + 1}`, + }} + fileErrors={fileErrors} + showFileLastModified + showFileSize + tokenLimit={5} + constraintText="JSON input documents" + /> + + {uploadFileList.length > 0 && ( + + )} +
From b06d1b9c9391298cc25869765f477694a3228f45 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Fri, 25 Apr 2025 21:11:36 -0600 Subject: [PATCH 03/25] Add code view popover Signed-off-by: Mikayla Thompson --- frontend/package-lock.json | 33 ++++++++++++++++--- frontend/package.json | 3 +- .../playground/InputDocumentSection.tsx | 20 ++++++++++- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3fb986689e..ccb572efc6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "dependencies": { "@cloudscape-design/board-components": "^3.0.99", - "@cloudscape-design/components": "^3.0.947", + "@cloudscape-design/code-view": "^3.0.52", + "@cloudscape-design/components": "^3.0.950", "@cloudscape-design/global-styles": "^1.0.41", "@cloudscape-design/test-utils-core": "^1.0.56", "@types/uuid": "^10.0.0", @@ -616,6 +617,21 @@ "react-dom": "^18.2.0" } }, + "node_modules/@cloudscape-design/code-view": { + "version": "3.0.52", + "resolved": "https://registry.npmjs.org/@cloudscape-design/code-view/-/code-view-3.0.52.tgz", + "integrity": "sha512-yLUkaQuSqXxfE1YrEBBsVN0viU9dNgcl3Tv6zryJY5bc2mEHLdlAmAtNp42YQi+pmE1uHQPrPVTr9Kfz/atnDw==", + "license": "Apache-2.0", + "dependencies": { + "@cloudscape-design/component-toolkit": "^1.0.0-beta", + "ace-code": "^1.32.3", + "clsx": "^1.2.1" + }, + "peerDependencies": { + "@cloudscape-design/components": "^3", + "react": ">=18.2.0" + } + }, "node_modules/@cloudscape-design/collection-hooks": { "version": "1.0.70", "resolved": "https://registry.npmjs.org/@cloudscape-design/collection-hooks/-/collection-hooks-1.0.70.tgz", @@ -636,9 +652,9 @@ } }, "node_modules/@cloudscape-design/components": { - "version": "3.0.947", - "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.947.tgz", - "integrity": "sha512-VofolVfbpwObTvI6Gps+MsuMk/aRZua6+HI2wcns9lK8AdNpTpSHO9aBiSB+VKErXZ3iuOVsluPGZ2J1wWJpNw==", + "version": "3.0.950", + "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.950.tgz", + "integrity": "sha512-5iVYZJ4hRjTyCGRJfqrwAzEVfupnb/GsyAmcJSSFeJxU6RERw3WukOijVjxEQMsW1lyi9xLSPwVFzEQP9OIhPw==", "license": "Apache-2.0", "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", @@ -3095,6 +3111,15 @@ "integrity": "sha512-wOCyJfNRsq/yLTR7z2KpwcjaInuUs/mosu/OFLGGUA+g+ApD9OJ1AToHDIp0Xpa2koHJ79bmOya73oWjCNbjlA==", "license": "BSD-3-Clause" }, + "node_modules/ace-code": { + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/ace-code/-/ace-code-1.40.1.tgz", + "integrity": "sha512-KR1LwXT7BxaBudJ7ue/sBkc2TsO0P++RoDjxum7N32nn2Lx7kaLg5VGe6vvZvoC8PxN3Iay/B9oadz0UB/6wNw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d9ebe24f7c..008cb8a452 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ }, "dependencies": { "@cloudscape-design/board-components": "^3.0.99", - "@cloudscape-design/components": "^3.0.947", + "@cloudscape-design/code-view": "^3.0.52", + "@cloudscape-design/components": "^3.0.950", "@cloudscape-design/global-styles": "^1.0.41", "@cloudscape-design/test-utils-core": "^1.0.56", "@types/uuid": "^10.0.0", diff --git a/frontend/src/components/playground/InputDocumentSection.tsx b/frontend/src/components/playground/InputDocumentSection.tsx index ea78748caf..645e8f12e8 100644 --- a/frontend/src/components/playground/InputDocumentSection.tsx +++ b/frontend/src/components/playground/InputDocumentSection.tsx @@ -6,9 +6,12 @@ import SpaceBetween from "@cloudscape-design/components/space-between"; import Box from "@cloudscape-design/components/box"; import FileUpload from "@cloudscape-design/components/file-upload"; import FormField from "@cloudscape-design/components/form-field"; +import CodeView from "@cloudscape-design/code-view/code-view"; +import javascriptHighlight from "@cloudscape-design/code-view/highlight/javascript"; import { usePlayground } from "../../context/PlaygroundContext"; import { usePlaygroundActions } from "../../hooks/usePlaygroundActions"; +import { Popover } from "@cloudscape-design/components"; export default function InputDocumentSection() { const { state } = usePlayground(); @@ -88,7 +91,22 @@ export default function InputDocumentSection() { {state.inputDocuments.length === 0 ? ( No input documents. ) : ( - state.inputDocuments.map((doc) => {doc.name}) + state.inputDocuments.map((doc) => ( + + } + > + {doc.name} + + )) )}
From 35eba9da0af543550479a15a74ddb23b8286acf8 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Fri, 25 Apr 2025 21:30:15 -0600 Subject: [PATCH 04/25] format content before displaying Signed-off-by: Mikayla Thompson --- .../components/playground/InputDocumentSection.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/playground/InputDocumentSection.tsx b/frontend/src/components/playground/InputDocumentSection.tsx index 645e8f12e8..1523a7bb89 100644 --- a/frontend/src/components/playground/InputDocumentSection.tsx +++ b/frontend/src/components/playground/InputDocumentSection.tsx @@ -85,6 +85,16 @@ export default function InputDocumentSection() { } }; + const prettyPrintJson = (json: string): string => { + try { + const parsed = JSON.parse(json); + return JSON.stringify(parsed, null, 2); + } catch (e) { + console.error("Error pretty printing JSON:", e); + return json; // Return original if error occurs + } + }; + return ( Input Documents}> @@ -99,7 +109,7 @@ export default function InputDocumentSection() { renderWithPortal content={ } @@ -114,6 +124,7 @@ export default function InputDocumentSection() { { setUploadFileList(detail.value); + setFileErrors([]); }} value={uploadFileList} multiple From 60ab771d2fdc66197681f83c16e0919e27da2a21 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Fri, 25 Apr 2025 21:53:32 -0600 Subject: [PATCH 05/25] Add eslint rule and fixes for relative imports Signed-off-by: Mikayla Thompson --- frontend/eslint.config.mjs | 15 +++++++++++++++ frontend/src/app/playground/page.tsx | 6 +++--- .../playground/InputDocumentSection.tsx | 6 +++--- .../playground/OutputDocumentSection.tsx | 2 +- .../playground/TransformationSection.tsx | 4 ++-- frontend/src/hooks/usePlaygroundActions.ts | 2 +- 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 3a9bb6218f..c2ade58cb4 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -11,6 +11,21 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript", "plugin:prettier/recommended"), + { + rules: { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["../"], + "message": "Relative imports are not allowed." + } + ] + } + ] + }, +}, ]; export default eslintConfig; diff --git a/frontend/src/app/playground/page.tsx b/frontend/src/app/playground/page.tsx index 477d7f2a38..71220fa052 100644 --- a/frontend/src/app/playground/page.tsx +++ b/frontend/src/app/playground/page.tsx @@ -5,9 +5,9 @@ import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; import { PlaygroundProvider } from "@/context/PlaygroundProvider"; import { Container, Grid } from "@cloudscape-design/components"; -import InputDocumentSection from "../../components/playground/InputDocumentSection"; -import OutputDocumentSection from "../../components/playground/OutputDocumentSection"; -import TransformationSection from "../../components/playground/TransformationSection"; +import InputDocumentSection from "@/components/playground/InputDocumentSection"; +import OutputDocumentSection from "@/components/playground/OutputDocumentSection"; +import TransformationSection from "@/components/playground/TransformationSection"; export default function Home() { return ( diff --git a/frontend/src/components/playground/InputDocumentSection.tsx b/frontend/src/components/playground/InputDocumentSection.tsx index 1523a7bb89..07672de612 100644 --- a/frontend/src/components/playground/InputDocumentSection.tsx +++ b/frontend/src/components/playground/InputDocumentSection.tsx @@ -9,8 +9,8 @@ import FormField from "@cloudscape-design/components/form-field"; import CodeView from "@cloudscape-design/code-view/code-view"; import javascriptHighlight from "@cloudscape-design/code-view/highlight/javascript"; -import { usePlayground } from "../../context/PlaygroundContext"; -import { usePlaygroundActions } from "../../hooks/usePlaygroundActions"; +import { usePlayground } from "@/context/PlaygroundContext"; +import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; import { Popover } from "@cloudscape-design/components"; export default function InputDocumentSection() { @@ -81,7 +81,7 @@ export default function InputDocumentSection() { } } } - return "File is not valid JSON data"; + return `Invalid JSON format in file: ${e instanceof Error ? e.message : "Unknown error"}`; } }; diff --git a/frontend/src/components/playground/OutputDocumentSection.tsx b/frontend/src/components/playground/OutputDocumentSection.tsx index 596f54086c..3c216b52bf 100644 --- a/frontend/src/components/playground/OutputDocumentSection.tsx +++ b/frontend/src/components/playground/OutputDocumentSection.tsx @@ -2,7 +2,7 @@ import Container from "@cloudscape-design/components/container"; import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Box from "@cloudscape-design/components/box"; -import { usePlayground } from "../../context/PlaygroundContext"; +import { usePlayground } from "@/context/PlaygroundContext"; // Inner component that uses the usePlayground hook export default function OutputDocumentSection() { diff --git a/frontend/src/components/playground/TransformationSection.tsx b/frontend/src/components/playground/TransformationSection.tsx index b311c1cc05..aa1b62bfd6 100644 --- a/frontend/src/components/playground/TransformationSection.tsx +++ b/frontend/src/components/playground/TransformationSection.tsx @@ -5,8 +5,8 @@ import Header from "@cloudscape-design/components/header"; import Button from "@cloudscape-design/components/button"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Box from "@cloudscape-design/components/box"; -import { usePlayground } from "../../context/PlaygroundContext"; -import { usePlaygroundActions } from "../../hooks/usePlaygroundActions"; +import { usePlayground } from "@/context/PlaygroundContext"; +import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; export default function TransformationSection() { const { state } = usePlayground(); diff --git a/frontend/src/hooks/usePlaygroundActions.ts b/frontend/src/hooks/usePlaygroundActions.ts index 3d48a430cd..0f4a51fb77 100644 --- a/frontend/src/hooks/usePlaygroundActions.ts +++ b/frontend/src/hooks/usePlaygroundActions.ts @@ -4,7 +4,7 @@ import { usePlayground, InputDocument, Transformation, -} from "../context/PlaygroundContext"; +} from "@/context/PlaygroundContext"; export const usePlaygroundActions = () => { const { dispatch } = usePlayground(); From 2e2bdbcf189336c36cc2348659512f28b96efa78 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Fri, 25 Apr 2025 22:06:36 -0600 Subject: [PATCH 06/25] Refactor Signed-off-by: Mikayla Thompson --- .../DocumentItemWithPopoverCodeView.tsx | 31 +++++ .../playground/InputDocumentSection.tsx | 129 +++++------------- frontend/src/hooks/useFileUpload.ts | 69 ++++++++++ frontend/src/utils/jsonUtils.ts | 55 ++++++++ 4 files changed, 190 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx create mode 100644 frontend/src/hooks/useFileUpload.ts create mode 100644 frontend/src/utils/jsonUtils.ts diff --git a/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx b/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx new file mode 100644 index 0000000000..1a53ccfde6 --- /dev/null +++ b/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import Box from "@cloudscape-design/components/box"; +import { Popover } from "@cloudscape-design/components"; +import CodeView from "@cloudscape-design/code-view/code-view"; +import javascriptHighlight from "@cloudscape-design/code-view/highlight/javascript"; +import { prettyPrintJson } from "@/utils/jsonUtils"; +import { InputDocument } from "@/context/PlaygroundContext"; + +interface DocumentItemWithPopoverCodeViewProps { + document: InputDocument; +} + +export const DocumentItemWithPopoverCodeView: React.FC< + DocumentItemWithPopoverCodeViewProps +> = ({ document }) => ( + + } + > + {document.name} + +); diff --git a/frontend/src/components/playground/InputDocumentSection.tsx b/frontend/src/components/playground/InputDocumentSection.tsx index 07672de612..4abf044ac1 100644 --- a/frontend/src/components/playground/InputDocumentSection.tsx +++ b/frontend/src/components/playground/InputDocumentSection.tsx @@ -6,93 +6,40 @@ import SpaceBetween from "@cloudscape-design/components/space-between"; import Box from "@cloudscape-design/components/box"; import FileUpload from "@cloudscape-design/components/file-upload"; import FormField from "@cloudscape-design/components/form-field"; -import CodeView from "@cloudscape-design/code-view/code-view"; -import javascriptHighlight from "@cloudscape-design/code-view/highlight/javascript"; +import Spinner from "@cloudscape-design/components/spinner"; import { usePlayground } from "@/context/PlaygroundContext"; import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; -import { Popover } from "@cloudscape-design/components"; +import { useFileUpload } from "@/hooks/useFileUpload"; +import { DocumentItemWithPopoverCodeView } from "./DocumentItemWithPopoverCodeView"; export default function InputDocumentSection() { const { state } = usePlayground(); const { addInputDocument } = usePlaygroundActions(); - - const [uploadFileList, setUploadFileList] = React.useState([]); - const [fileErrors, setFileErrors] = React.useState<(string | null)[]>([]); + const { + files, + setFiles, + errors, + setErrors, + isProcessing, + processFiles, + clearSuccessfulFiles, + } = useFileUpload(); const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); - if (uploadFileList.length === 0) return; - setFileErrors([]); - - try { - for (const [i, file] of uploadFileList.entries()) { - // Read file content - const content = await readFileAsText(file); - - // Validate JSON or newline-delimited JSON - const potentialError = validateJsonContent(content); - if (potentialError) { - setFileErrors([...fileErrors, potentialError]); - continue; // Skip this file if there's an error - } + const results = await processFiles(); - // Add as new input document - addInputDocument(file.name, content); - // Remove the file from the upload list - setUploadFileList((prev) => prev.filter((_, index) => index !== i)); + // Add successful documents to the playground state + results.forEach((result) => { + if (result.success && result.content) { + addInputDocument(result.fileName, result.content); } - } catch (error) { - console.error("Error processing files:", error); - } - }; - - // Helper function to read file content - const readFileAsText = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = () => - reject(new Error(`Failed to read file: ${file.name}`)); - reader.readAsText(file); }); - }; - // Helper function to validate JSON content - const validateJsonContent = (content: string): string | null => { - try { - // Try parsing as regular JSON first - JSON.parse(content); - return null; - } catch (e) { - // If not regular JSON, check if it's newline-delimited JSON - const lines = content.trim().split("\n"); - - for (const line of lines) { - if (line.trim()) { - try { - JSON.parse(line); // Will throw if invalid - return null; // Valid JSON found - } catch (lineError: unknown) { - const errorMessage = - lineError instanceof Error ? lineError.message : "Unknown error"; - return `Invalid JSON format in file: ${errorMessage}`; - } - } - } - return `Invalid JSON format in file: ${e instanceof Error ? e.message : "Unknown error"}`; - } - }; - - const prettyPrintJson = (json: string): string => { - try { - const parsed = JSON.parse(json); - return JSON.stringify(parsed, null, 2); - } catch (e) { - console.error("Error pretty printing JSON:", e); - return json; // Return original if error occurs - } + // Clear successfully processed files from the upload list + clearSuccessfulFiles(results); }; return ( @@ -102,31 +49,19 @@ export default function InputDocumentSection() { No input documents. ) : ( state.inputDocuments.map((doc) => ( - - } - > - {doc.name} - + )) )} + - + 0 ? errors : undefined}> { - setUploadFileList(detail.value); - setFileErrors([]); + setFiles(detail.value); + setErrors([]); }} - value={uploadFileList} + value={files} multiple accept="application/json" i18nStrings={{ @@ -135,15 +70,21 @@ export default function InputDocumentSection() { e ? "Drop files to upload" : "Drop file to upload", removeFileAriaLabel: (e) => `Remove file ${e + 1}`, }} - fileErrors={fileErrors} showFileLastModified showFileSize tokenLimit={5} - constraintText="JSON input documents" + constraintText="JSON or newline-delimited JSON files only" /> - {uploadFileList.length > 0 && ( - + + {isProcessing ? ( + + ) : ( + files.length > 0 && ( + + ) )} diff --git a/frontend/src/hooks/useFileUpload.ts b/frontend/src/hooks/useFileUpload.ts new file mode 100644 index 0000000000..d17168fd48 --- /dev/null +++ b/frontend/src/hooks/useFileUpload.ts @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { readFileAsText, validateJsonContent } from "@/utils/jsonUtils"; + +interface FileProcessingResult { + success: boolean; + fileName: string; + content?: string; + error?: string; +} + +export function useFileUpload() { + const [files, setFiles] = useState([]); + const [errors, setErrors] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + + const processFiles = async (): Promise => { + if (files.length === 0) return []; + + setIsProcessing(true); + const results: FileProcessingResult[] = []; + const newErrors: string[] = []; + + try { + for (const file of files) { + try { + const content = await readFileAsText(file); + const validationError = validateJsonContent(content); + + if (validationError) { + const errorMsg = `Error in ${file.name}: ${validationError}`; + newErrors.push(errorMsg); + results.push({ success: false, fileName: file.name, error: validationError }); + } else { + results.push({ success: true, fileName: file.name, content }); + } + } catch (error) { + const errorMsg = `Failed to process ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`; + newErrors.push(errorMsg); + results.push({ success: false, fileName: file.name, error: errorMsg }); + } + } + } finally { + setErrors(newErrors); + setIsProcessing(false); + } + + return results; + }; + + const clearSuccessfulFiles = (results: FileProcessingResult[]) => { + const successfulFileNames = results + .filter(result => result.success) + .map(result => result.fileName); + + setFiles(prevFiles => + prevFiles.filter(file => !successfulFileNames.includes(file.name)) + ); + }; + + return { + files, + setFiles, + errors, + setErrors, + isProcessing, + processFiles, + clearSuccessfulFiles + }; +} diff --git a/frontend/src/utils/jsonUtils.ts b/frontend/src/utils/jsonUtils.ts new file mode 100644 index 0000000000..d4f2ad0573 --- /dev/null +++ b/frontend/src/utils/jsonUtils.ts @@ -0,0 +1,55 @@ +export const readFileAsText = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)); + reader.readAsText(file); + }); +}; + +export const validateJsonContent = (content: string): string | null => { + try { + // Try parsing as regular JSON first + JSON.parse(content); + return null; + } catch (e) { + // If not regular JSON, check if it's newline-delimited JSON + return validateNewlineDelimitedJson(content); + } +}; + +export const validateNewlineDelimitedJson = (content: string): string | null => { + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (line.trim()) { + try { + JSON.parse(line); + } catch (lineError) { + return `Invalid JSON format: ${lineError instanceof Error ? lineError.message : "Unknown error"}`; + } + } + } + return null; // All lines are valid JSON +}; + +export const prettyPrintJson = (json: string): string => { + try { + const parsed = JSON.parse(json); + return JSON.stringify(parsed, null, 2); + } catch (e) { + // For newline-delimited JSON, try to pretty print each line + try { + const lines = json.trim().split("\n"); + return lines + .map(line => { + if (!line.trim()) return ""; + const parsed = JSON.parse(line); + return JSON.stringify(parsed, null, 2); + }) + .join("\n\n"); + } catch { + return json; // Return original if all attempts fail + } + } +}; From 769b07c0eb50059b886313784b05813855874d82 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Fri, 25 Apr 2025 22:13:19 -0600 Subject: [PATCH 07/25] Add delete option Signed-off-by: Mikayla Thompson --- .../hooks/usePlaygroundActions.test.tsx | 43 +++++++++++++++++ .../DocumentItemWithPopoverCodeView.tsx | 47 +++++++++++++------ .../playground/InputDocumentSection.tsx | 8 +++- frontend/src/hooks/usePlaygroundActions.ts | 8 ++++ 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/frontend/__tests__/hooks/usePlaygroundActions.test.tsx b/frontend/__tests__/hooks/usePlaygroundActions.test.tsx index ae3a9d7f84..7f2142c378 100644 --- a/frontend/__tests__/hooks/usePlaygroundActions.test.tsx +++ b/frontend/__tests__/hooks/usePlaygroundActions.test.tsx @@ -84,6 +84,49 @@ describe("usePlaygroundActions", () => { }); }); + describe("removeInputDocument", () => { + it("should dispatch the REMOVE_INPUT_DOCUMENT action with correct payload", () => { + const { wrapper, mockDispatch } = createMockWrapper(); + const { result } = renderHook(() => usePlaygroundActions(), { wrapper }); + + const documentId = "test-doc-id"; + + act(() => { + result.current.removeInputDocument(documentId); + }); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ + type: "REMOVE_INPUT_DOCUMENT", + payload: documentId, + }); + }); + + it("should correctly remove a document when integrated with the provider", () => { + const { result } = renderHook(() => usePlaygroundActions(), { + wrapper: standardWrapper, + }); + + // First add a document + let documentId: string; + act(() => { + const doc = result.current.addInputDocument( + TEST_DOC_NAME, + TEST_DOC_CONTENT + ); + documentId = doc.id; + }); + + // Then remove it + act(() => { + result.current.removeInputDocument(documentId); + }); + + // We can verify the function exists and doesn't throw + expect(result.current.removeInputDocument).toBeInstanceOf(Function); + }); + }); + describe("addTransformation", () => { it("should create a new transformation with the correct structure", () => { const { result } = renderHook(() => usePlaygroundActions(), { diff --git a/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx b/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx index 1a53ccfde6..6ec8506d39 100644 --- a/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx +++ b/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx @@ -3,29 +3,46 @@ import Box from "@cloudscape-design/components/box"; import { Popover } from "@cloudscape-design/components"; import CodeView from "@cloudscape-design/code-view/code-view"; import javascriptHighlight from "@cloudscape-design/code-view/highlight/javascript"; +import Button from "@cloudscape-design/components/button"; +import SpaceBetween from "@cloudscape-design/components/space-between"; import { prettyPrintJson } from "@/utils/jsonUtils"; import { InputDocument } from "@/context/PlaygroundContext"; interface DocumentItemWithPopoverCodeViewProps { document: InputDocument; + onDelete?: (id: string) => void; } export const DocumentItemWithPopoverCodeView: React.FC< DocumentItemWithPopoverCodeViewProps -> = ({ document }) => ( - = ({ document, onDelete }) => ( + + + } + > + {document.name} + + {onDelete && ( + ) diff --git a/frontend/src/context/PlaygroundContext.tsx b/frontend/src/context/PlaygroundContext.tsx index 98597ecaa8..b93b65b699 100644 --- a/frontend/src/context/PlaygroundContext.tsx +++ b/frontend/src/context/PlaygroundContext.tsx @@ -147,6 +147,8 @@ export const playgroundReducer = ( type PlaygroundContextType = { state: PlaygroundState; dispatch: React.Dispatch; + storageSize: number; + isQuotaExceeded: boolean; }; export const PlaygroundContext = createContext< @@ -174,6 +176,8 @@ export const usePlayground = () => { return { state: initialState, dispatch: mockDispatch, + storageSize: 0, + isQuotaExceeded: false, }; } diff --git a/frontend/src/context/PlaygroundProvider.tsx b/frontend/src/context/PlaygroundProvider.tsx index bc5e897a1a..df860aa5f0 100644 --- a/frontend/src/context/PlaygroundProvider.tsx +++ b/frontend/src/context/PlaygroundProvider.tsx @@ -1,10 +1,13 @@ "use client"; -import React, { useReducer, useEffect, useMemo } from "react"; +import React, { useReducer, useEffect, useMemo, useState } from "react"; +import { MAX_TOTAL_STORAGE_BYTES, formatBytes } from "@/utils/sizeLimits"; import { playgroundReducer, initialState, STORAGE_KEY, PlaygroundContext, + InputDocument, + Transformation, } from "./PlaygroundContext"; // Provider to persist/load from localStorage, for input documents and transformations @@ -14,11 +17,18 @@ export const PlaygroundProvider: React.FC<{ children: React.ReactNode }> = ({ }) => { const [state, dispatch] = useReducer(playgroundReducer, initialState); + // Track total storage size and quota exceeded state + const [totalStorageSize, setTotalStorageSize] = useState(0); + const [isQuotaExceeded, setIsQuotaExceeded] = useState(false); + // Load state from localStorage on initial render (only inputDocuments and transformations) useEffect(() => { try { const savedState = localStorage.getItem(STORAGE_KEY); if (savedState) { + const sizeInBytes = new Blob([savedState]).size; + setTotalStorageSize(sizeInBytes); + const parsedState = JSON.parse(savedState); dispatch({ type: "SET_STATE", @@ -33,20 +43,78 @@ export const PlaygroundProvider: React.FC<{ children: React.ReactNode }> = ({ } }, []); + // Keep track of the previous valid state that was successfully saved + const [lastValidState, setLastValidState] = useState<{ + inputDocuments: InputDocument[]; + transformations: Transformation[]; + }>({ inputDocuments: [], transformations: [] }); + // Save state to localStorage whenever it changes (excluding outputDocuments) useEffect(() => { - try { - const stateToSave = { - inputDocuments: state.inputDocuments, - transformations: state.transformations, - }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); - } catch (error) { - console.error("Failed to save state to localStorage:", error); + // Skip the effect if we're in the process of reverting to the last valid state + console.log("In the save state effect"); + const stateToSave = { + inputDocuments: state.inputDocuments, + transformations: state.transformations, + }; + + const stateJson = JSON.stringify(stateToSave); + + // Update the total storage size + const sizeInBytes = new Blob([stateJson]).size; + setTotalStorageSize(sizeInBytes); + + // Only try to save to localStorage if we're not in a quota exceeded state + if (!isQuotaExceeded) { + try { + localStorage.setItem(STORAGE_KEY, stateJson); + + // Update the last valid state after successful save + setLastValidState(stateToSave); + setIsQuotaExceeded(false); // Reset the quota exceeded flag + } catch (storageError) { + // Check if it's a quota exceeded error + if ( + storageError instanceof DOMException && + (storageError.name === "QuotaExceededError" || + storageError.name === "NS_ERROR_DOM_QUOTA_REACHED" || + storageError.message.includes("quota")) + ) { + const errorMsg = `Storage quota exceeded. Maximum storage limit is approximately ${formatBytes(MAX_TOTAL_STORAGE_BYTES)}.`; + console.error(errorMsg); + + // Set the quota exceeded flag + setIsQuotaExceeded(true); + return; + } else { + // Log other errors + console.error("Failed to save state to localStorage:", storageError); + } + } } - }, [state.inputDocuments, state.transformations]); + }, [state.inputDocuments, state.transformations, isQuotaExceeded]); - const contextValue = useMemo(() => ({ state, dispatch }), [state, dispatch]); + // Effect to revert to last valid state when quota is exceeded + useEffect(() => { + console.log("In the quota exceeded effect"); + if (isQuotaExceeded && lastValidState.inputDocuments.length > 0) { + console.log("Reverting to last valid state"); + dispatch({ + type: "SET_STATE", + payload: lastValidState, + }); + } + }, [isQuotaExceeded, lastValidState, dispatch]); + + const contextValue = useMemo( + () => ({ + state, + dispatch, + storageSize: totalStorageSize, + isQuotaExceeded, + }), + [state, dispatch, totalStorageSize, isQuotaExceeded], + ); return ( diff --git a/frontend/src/hooks/useJSONFileUpload.ts b/frontend/src/hooks/useJSONFileUpload.ts index 1956d2f825..fa95168f45 100644 --- a/frontend/src/hooks/useJSONFileUpload.ts +++ b/frontend/src/hooks/useJSONFileUpload.ts @@ -4,6 +4,7 @@ import { validateJsonContent, isNewlineDelimitedJson, } from "@/utils/jsonUtils"; +import { MAX_DOCUMENT_SIZE_BYTES, formatBytes } from "@/utils/sizeLimits"; interface FileProcessingResult { success: boolean; @@ -45,6 +46,18 @@ export function useJSONFileUpload() { const errors: string[] = []; try { + // Check file size before processing + if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + const errorMsg = `File ${file.name} exceeds the maximum size limit of ${formatBytes(MAX_DOCUMENT_SIZE_BYTES)}`; + errors.push(errorMsg); + results.push({ + success: false, + fileName: file.name, + error: errorMsg, + }); + return { results, errors }; + } + const content = await readFileAsText(file); const validationError = validateJsonContent(content); diff --git a/frontend/src/hooks/usePlaygroundActions.ts b/frontend/src/hooks/usePlaygroundActions.ts index c3337cefd6..34f40b9d34 100644 --- a/frontend/src/hooks/usePlaygroundActions.ts +++ b/frontend/src/hooks/usePlaygroundActions.ts @@ -5,12 +5,31 @@ import { InputDocument, Transformation, } from "@/context/PlaygroundContext"; +import { getJsonSizeInBytes } from "@/utils/jsonUtils"; +import { MAX_TOTAL_STORAGE_BYTES, formatBytes } from "@/utils/sizeLimits"; export const usePlaygroundActions = () => { - const { dispatch } = usePlayground(); + const { dispatch, storageSize, isQuotaExceeded } = usePlayground(); const addInputDocument = useCallback( (name: string, content: string) => { + // Check if quota is already exceeded + if (isQuotaExceeded) { + throw new Error( + `Cannot add document: Local storage quota would be exceeded. Please remove some documents first.`, + ); + } + + // Calculate size of new document + const newDocSize = getJsonSizeInBytes(content); + + // Check if adding this document would exceed the storage limit + if (storageSize + newDocSize > MAX_TOTAL_STORAGE_BYTES) { + throw new Error( + `Adding this document would exceed the maximum storage limit of ${formatBytes(MAX_TOTAL_STORAGE_BYTES)}`, + ); + } + const newDoc: InputDocument = { id: uuidv4(), name, @@ -19,7 +38,7 @@ export const usePlaygroundActions = () => { dispatch({ type: "ADD_INPUT_DOCUMENT", payload: newDoc }); return newDoc; }, - [dispatch], + [dispatch, storageSize, isQuotaExceeded], ); const removeInputDocument = useCallback( diff --git a/frontend/src/utils/jsonUtils.ts b/frontend/src/utils/jsonUtils.ts index ebb7a48db5..382e160b7b 100644 --- a/frontend/src/utils/jsonUtils.ts +++ b/frontend/src/utils/jsonUtils.ts @@ -76,3 +76,12 @@ export const prettyPrintJson = (json: string): string => { return json; } }; + +/** + * Calculates the size of a JSON string in bytes + * @param jsonString The JSON string to measure + * @returns The size in bytes + */ +export const getJsonSizeInBytes = (jsonString: string): number => { + return new Blob([jsonString]).size; +}; diff --git a/frontend/src/utils/sizeLimits.ts b/frontend/src/utils/sizeLimits.ts new file mode 100644 index 0000000000..86936f5c87 --- /dev/null +++ b/frontend/src/utils/sizeLimits.ts @@ -0,0 +1,16 @@ +export const MAX_DOCUMENT_SIZE_MB = 5; // Maximum size for individual documents in MB +export const MAX_DOCUMENT_SIZE_BYTES = MAX_DOCUMENT_SIZE_MB * 1024 * 1024; // 5MB in bytes + +export const MAX_TOTAL_STORAGE_MB = 10; // Maximum total storage size in MB +export const MAX_TOTAL_STORAGE_BYTES = MAX_TOTAL_STORAGE_MB * 1024 * 1024; // 10MB in bytes + +// Helper function to format bytes to human-readable format +export const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 Bytes"; + + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +}; From 592f33ce25912144109fb2a38b5f1756923d9003 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Sat, 26 Apr 2025 02:00:49 -0600 Subject: [PATCH 11/25] Add editing feature Signed-off-by: Mikayla Thompson --- frontend/next.config.ts | 8 + frontend/package-lock.json | 7 +- frontend/package.json | 1 + .../DocumentItemWithPopoverCodeView.tsx | 15 +- .../playground/EditDocumentModal.tsx | 151 ++++++++++++++++++ .../playground/InputDocumentSection.tsx | 58 +++++-- frontend/src/context/PlaygroundProvider.tsx | 3 - frontend/src/hooks/useJSONFileUpload.ts | 2 +- frontend/src/hooks/usePlaygroundActions.ts | 29 +++- frontend/typings.d.ts | 4 + 10 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/playground/EditDocumentModal.tsx create mode 100644 frontend/typings.d.ts diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 6cbbbab961..b3a36a3c5c 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,6 +9,14 @@ const nextConfig: NextConfig = { "@cloudscape-design/global-styles", "@cloudscape-design/component-toolkit" ], + webpack(config) { + // h/t https://github.com/securingsincity/react-ace/issues/725#issuecomment-1407356137 + config.module.rules.push({ + test: /ace-builds.*\/worker-.*$/, + type: "asset/resource", + }); + return config; + }, }; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ccb572efc6..54c2083e05 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@cloudscape-design/global-styles": "^1.0.41", "@cloudscape-design/test-utils-core": "^1.0.56", "@types/uuid": "^10.0.0", + "ace-builds": "^1.40.1", "next": "15.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -3106,9 +3107,9 @@ "license": "BSD-3-Clause" }, "node_modules/ace-builds": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.40.0.tgz", - "integrity": "sha512-wOCyJfNRsq/yLTR7z2KpwcjaInuUs/mosu/OFLGGUA+g+ApD9OJ1AToHDIp0Xpa2koHJ79bmOya73oWjCNbjlA==", + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.40.1.tgz", + "integrity": "sha512-32uwJNwmhqpnYtr6oq8RoO1D6F6tnxisv5f9w2XPX3vi4QruuHNikadHUiHvnxLAV1n5Azv4LFtpItQ5dD1eRw==", "license": "BSD-3-Clause" }, "node_modules/ace-code": { diff --git a/frontend/package.json b/frontend/package.json index 008cb8a452..e830798f7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@cloudscape-design/global-styles": "^1.0.41", "@cloudscape-design/test-utils-core": "^1.0.56", "@types/uuid": "^10.0.0", + "ace-builds": "^1.40.1", "next": "15.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx b/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx index 6ec8506d39..7a01b52ac2 100644 --- a/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx +++ b/frontend/src/components/playground/DocumentItemWithPopoverCodeView.tsx @@ -11,11 +11,12 @@ import { InputDocument } from "@/context/PlaygroundContext"; interface DocumentItemWithPopoverCodeViewProps { document: InputDocument; onDelete?: (id: string) => void; + onEdit?: (document: InputDocument) => void; } export const DocumentItemWithPopoverCodeView: React.FC< DocumentItemWithPopoverCodeViewProps -> = ({ document, onDelete }) => ( +> = ({ document, onDelete, onEdit }) => ( {document.name} + {onEdit && ( + + + + + } + > + + + setName(detail.value)} + /> + + + + handleCodeChange(detail.value)} + preferences={preferences} + onPreferencesChange={(e) => setPreferences(e.detail)} + ace={ace} + i18nStrings={{ + loadingState: "Loading code editor", + errorState: "There was an error loading the code editor.", + errorStateRecovery: "Retry", + editorGroupAriaLabel: "Code editor", + statusBarGroupAriaLabel: "Status bar", + cursorPosition: (row, column) => `Ln ${row}, Col ${column}`, + errorsTab: "Errors", + warningsTab: "Warnings", + preferencesButtonAriaLabel: "Preferences", + paneCloseButtonAriaLabel: "Close", + preferencesModalHeader: "Preferences", + preferencesModalCancel: "Cancel", + preferencesModalConfirm: "Confirm", + preferencesModalWrapLines: "Wrap lines", + preferencesModalTheme: "Theme", + preferencesModalLightThemes: "Light themes", + preferencesModalDarkThemes: "Dark themes", + }} + /> + + + + ); +}; diff --git a/frontend/src/components/playground/InputDocumentSection.tsx b/frontend/src/components/playground/InputDocumentSection.tsx index 36dfd68397..3fd4e8874e 100644 --- a/frontend/src/components/playground/InputDocumentSection.tsx +++ b/frontend/src/components/playground/InputDocumentSection.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import Container from "@cloudscape-design/components/container"; import Header from "@cloudscape-design/components/header"; import Button from "@cloudscape-design/components/button"; @@ -8,15 +8,38 @@ import FileUpload from "@cloudscape-design/components/file-upload"; import FormField from "@cloudscape-design/components/form-field"; import Spinner from "@cloudscape-design/components/spinner"; -import { usePlayground } from "@/context/PlaygroundContext"; +import { usePlayground, InputDocument } from "@/context/PlaygroundContext"; import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; import { useJSONFileUpload } from "@/hooks/useJSONFileUpload"; import { MAX_DOCUMENT_SIZE_MB } from "@/utils/sizeLimits"; import { DocumentItemWithPopoverCodeView } from "./DocumentItemWithPopoverCodeView"; +import { EditDocumentModal } from "./EditDocumentModal"; export default function InputDocumentSection() { const { state, isQuotaExceeded } = usePlayground(); - const { addInputDocument, removeInputDocument } = usePlaygroundActions(); + const { addInputDocument, removeInputDocument, updateInputDocument } = + usePlaygroundActions(); + + // State for edit modal + const [editingDocument, setEditingDocument] = useState( + null, + ); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + + // Handler for edit button click + const handleEditDocument = (document: InputDocument) => { + setEditingDocument(document); + setIsEditModalVisible(true); + }; + + // Handler for save changes + const handleSaveDocument = (id: string, name: string, content: string) => { + try { + updateInputDocument(id, name, content); + } catch (error) { + console.error("Failed to update document:", error); + } + }; const { files, setFiles, @@ -60,6 +83,17 @@ export default function InputDocumentSection() { clearSuccessfulFiles(results); }; + // Determine error text for file upload form field + const getErrorText = () => { + if (isQuotaExceeded) { + return "Local storage quota exceeded. Please remove some documents before adding more."; + } + if (errors.length > 0) { + return errors; + } + return undefined; + }; + return ( Input Documents}> @@ -71,21 +105,21 @@ export default function InputDocumentSection() { key={doc.id} document={doc} onDelete={removeInputDocument} + onEdit={handleEditDocument} /> )) )} + setIsEditModalVisible(false)} + onSave={handleSaveDocument} + /> + - 0 - ? errors - : undefined - } - > + { setFiles(detail.value); diff --git a/frontend/src/context/PlaygroundProvider.tsx b/frontend/src/context/PlaygroundProvider.tsx index df860aa5f0..f613d6107d 100644 --- a/frontend/src/context/PlaygroundProvider.tsx +++ b/frontend/src/context/PlaygroundProvider.tsx @@ -52,7 +52,6 @@ export const PlaygroundProvider: React.FC<{ children: React.ReactNode }> = ({ // Save state to localStorage whenever it changes (excluding outputDocuments) useEffect(() => { // Skip the effect if we're in the process of reverting to the last valid state - console.log("In the save state effect"); const stateToSave = { inputDocuments: state.inputDocuments, transformations: state.transformations, @@ -96,9 +95,7 @@ export const PlaygroundProvider: React.FC<{ children: React.ReactNode }> = ({ // Effect to revert to last valid state when quota is exceeded useEffect(() => { - console.log("In the quota exceeded effect"); if (isQuotaExceeded && lastValidState.inputDocuments.length > 0) { - console.log("Reverting to last valid state"); dispatch({ type: "SET_STATE", payload: lastValidState, diff --git a/frontend/src/hooks/useJSONFileUpload.ts b/frontend/src/hooks/useJSONFileUpload.ts index fa95168f45..c2098f7a42 100644 --- a/frontend/src/hooks/useJSONFileUpload.ts +++ b/frontend/src/hooks/useJSONFileUpload.ts @@ -113,7 +113,7 @@ export function useJSONFileUpload() { const clearSuccessfulFiles = (results: FileProcessingResult[]) => { const successfulFileNames = results .filter((result) => result.success) - .map((result) => result.fileName); + .map((result) => result.fileName.split(" [")[0]); // Remove line number if present setFiles((prevFiles) => prevFiles.filter((file) => !successfulFileNames.includes(file.name)), diff --git a/frontend/src/hooks/usePlaygroundActions.ts b/frontend/src/hooks/usePlaygroundActions.ts index 34f40b9d34..d7d167886f 100644 --- a/frontend/src/hooks/usePlaygroundActions.ts +++ b/frontend/src/hooks/usePlaygroundActions.ts @@ -5,7 +5,7 @@ import { InputDocument, Transformation, } from "@/context/PlaygroundContext"; -import { getJsonSizeInBytes } from "@/utils/jsonUtils"; +import { getJsonSizeInBytes, validateJsonContent } from "@/utils/jsonUtils"; import { MAX_TOTAL_STORAGE_BYTES, formatBytes } from "@/utils/sizeLimits"; export const usePlaygroundActions = () => { @@ -71,9 +71,36 @@ export const usePlaygroundActions = () => { [dispatch], ); + const updateInputDocument = useCallback( + (id: string, name: string, content: string) => { + // Check if quota is already exceeded + if (isQuotaExceeded) { + throw new Error( + `Cannot update document: Local storage quota would be exceeded. Please remove some documents first.`, + ); + } + + // Validate JSON content + const validationError = validateJsonContent(content); + if (validationError) { + throw new Error(`Invalid JSON: ${validationError}`); + } + + const updatedDoc: InputDocument = { + id, + name, + content, + }; + dispatch({ type: "UPDATE_INPUT_DOCUMENT", payload: updatedDoc }); + return updatedDoc; + }, + [dispatch, isQuotaExceeded], + ); + return { addInputDocument, removeInputDocument, + updateInputDocument, addTransformation, reorderTransformation, }; diff --git a/frontend/typings.d.ts b/frontend/typings.d.ts new file mode 100644 index 0000000000..2b214658a2 --- /dev/null +++ b/frontend/typings.d.ts @@ -0,0 +1,4 @@ +declare module "ace-builds/src-noconflict/worker-*" { + const workerPath: string; + export = workerPath; + } \ No newline at end of file From 140748cf1aacdbbf35065976a5694868b42a0eb6 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Tue, 29 Apr 2025 14:44:13 -0600 Subject: [PATCH 12/25] rough draft of transformation panel Signed-off-by: Mikayla Thompson --- frontend/package-lock.json | 38 ++++ frontend/package.json | 1 + .../playground/TransformationSection.tsx | 174 +++++++++++++++++- frontend/src/hooks/usePlaygroundActions.ts | 8 + 4 files changed, 211 insertions(+), 10 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 54c2083e05..8738d524ec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,7 @@ "ace-builds": "^1.40.1", "next": "15.3.0", "react": "^18.3.1", + "react-ace": "^14.0.1", "react-dom": "^18.3.1", "uuid": "^11.1.0" }, @@ -4405,6 +4406,12 @@ "node": ">=8" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -7780,6 +7787,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8733,6 +8754,23 @@ "node": ">=0.10.0" } }, + "node_modules/react-ace": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz", + "integrity": "sha512-z6YAZ20PNf/FqmYEic//G/UK6uw0rn21g58ASgHJHl9rfE4nITQLqthr9rHMVQK4ezwohJbp2dGrZpkq979PYQ==", + "license": "MIT", + "dependencies": { + "ace-builds": "^1.36.3", + "diff-match-patch": "^1.0.5", + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index e830798f7d..4dc1e72b11 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "ace-builds": "^1.40.1", "next": "15.3.0", "react": "^18.3.1", + "react-ace": "^14.0.1", "react-dom": "^18.3.1", "uuid": "^11.1.0" }, diff --git a/frontend/src/components/playground/TransformationSection.tsx b/frontend/src/components/playground/TransformationSection.tsx index aa1b62bfd6..13a8982c59 100644 --- a/frontend/src/components/playground/TransformationSection.tsx +++ b/frontend/src/components/playground/TransformationSection.tsx @@ -5,28 +5,182 @@ import Header from "@cloudscape-design/components/header"; import Button from "@cloudscape-design/components/button"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Box from "@cloudscape-design/components/box"; -import { usePlayground } from "@/context/PlaygroundContext"; +import Board, { BoardProps } from "@cloudscape-design/board-components/board"; +import BoardItem from "@cloudscape-design/board-components/board-item"; +import AceEditor from "react-ace"; + +import "ace-builds/src-noconflict/mode-json"; +import "ace-builds/src-noconflict/theme-github"; +import "ace-builds/src-noconflict/ext-language_tools"; + +import { Transformation, usePlayground } from "@/context/PlaygroundContext"; import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; +const boardItemI18nStrings = { + dragHandleAriaLabel: "Drag handle", + dragHandleAriaDescription: + "Use Space or Enter to activate drag, arrow keys to move, Space or Enter to submit, or Escape to discard. Be sure to temporarily disable any screen reader navigation feature that may interfere with the functionality of the arrow keys.", + resizeHandleAriaLabel: "Resize handle", + resizeHandleAriaDescription: + "Use Space or Enter to activate resize, arrow keys to move, Space or Enter to submit, or Escape to discard. Be sure to temporarily disable any screen reader navigation feature that may interfere with the functionality of the arrow keys.", +}; +const boardI18nStrings: BoardProps.I18nStrings = (() => { + function createAnnouncement( + operationAnnouncement: string, + conflicts: any, + disturbed: any + ) { + const conflictsAnnouncement = + conflicts.length > 0 + ? `Conflicts with ${conflicts.map((c) => c.data.title).join(", ")}.` + : ""; + const disturbedAnnouncement = + disturbed.length > 0 ? `Disturbed ${disturbed.length} items.` : ""; + return [operationAnnouncement, conflictsAnnouncement, disturbedAnnouncement] + .filter(Boolean) + .join(" "); + } + return { + liveAnnouncementDndStarted: (operationType) => + operationType === "resize" ? "Resizing" : "Dragging", + liveAnnouncementDndItemReordered: (operation) => { + const columns = `column ${operation.placement.x + 1}`; + const rows = `row ${operation.placement.y + 1}`; + return createAnnouncement( + `Item moved to ${ + operation.direction === "horizontal" ? columns : rows + }.`, + operation.conflicts, + operation.disturbed + ); + }, + liveAnnouncementDndItemResized: (operation) => { + const columnsConstraint = operation.isMinimalColumnsReached + ? " (minimal)" + : ""; + const rowsConstraint = operation.isMinimalRowsReached ? " (minimal)" : ""; + const sizeAnnouncement = + operation.direction === "horizontal" + ? `columns ${operation.placement.width}${columnsConstraint}` + : `rows ${operation.placement.height}${rowsConstraint}`; + return createAnnouncement( + `Item resized to ${sizeAnnouncement}.`, + operation.conflicts, + operation.disturbed + ); + }, + liveAnnouncementDndItemInserted: (operation) => { + const columns = `column ${operation.placement.x + 1}`; + const rows = `row ${operation.placement.y + 1}`; + return createAnnouncement( + `Item inserted to ${columns}, ${rows}.`, + operation.conflicts, + operation.disturbed + ); + }, + liveAnnouncementDndCommitted: (operationType) => + `${operationType} committed`, + liveAnnouncementDndDiscarded: (operationType) => + `${operationType} discarded`, + liveAnnouncementItemRemoved: (op) => + createAnnouncement( + `Removed item ${op.item.data.title}.`, + [], + op.disturbed + ), + navigationAriaLabel: "Board navigation", + navigationAriaDescription: "Click on non-empty item to move focus over", + navigationItemAriaLabel: (item) => (item ? item.data.title : "Empty"), + }; +})(); + export default function TransformationSection() { const { state } = usePlayground(); - const { addTransformation } = usePlaygroundActions(); + const { addTransformation, removeTransformation } = usePlaygroundActions(); const handleAddTransformation = () => { addTransformation(`Transformation ${state.transformations.length + 1}`, ""); }; + const handleRemoveTransportation = (id: string) => { + console.log(`Delete transformation with id: ${id}`); + removeTransformation(id); + }; + return ( Transformations}> - {state.transformations.length === 0 ? ( - No transformations. Add one to get started. - ) : ( - state.transformations.map((transform) => ( - {transform.name} - )) - )} - + ({ + id: transform.id, + rowSpan: 1, + columnSpan: 4, + data: transform, + }))} + i18nStrings={boardI18nStrings} + onItemsChange={(e) => { + e.preventDefault(); + console.log(e); + }} + renderItem={(item: BoardProps.Item) => ( + { + e.preventDefault(); + e.stopPropagation(); + handleRemoveTransportation(item.id); + }} + /> + } + > + {item.data.name} + { + console.log(e); + }} + onValidate={(e) => { + console.log("Validate:"); + console.log(e); + }} + name="item.id" + debounceChangePeriod={1000} // TODO: not convinced this is actually working + width="500px" + editorProps={{ $blockScrolling: true }} + setOptions={{ + enableBasicAutocompletion: true, + }} + /> + + } + i18nStrings={boardItemI18nStrings} + > + {item.data.content} + + )} + empty={ + + + + No items + + + + } + /> + + + ); diff --git a/frontend/src/hooks/usePlaygroundActions.ts b/frontend/src/hooks/usePlaygroundActions.ts index d7d167886f..fc4c636845 100644 --- a/frontend/src/hooks/usePlaygroundActions.ts +++ b/frontend/src/hooks/usePlaygroundActions.ts @@ -71,6 +71,13 @@ export const usePlaygroundActions = () => { [dispatch], ); + const removeTransformation = useCallback( + (id: string) => { + dispatch({ type: "REMOVE_TRANSFORMATION", payload: id }); + }, + [dispatch], + ); + const updateInputDocument = useCallback( (id: string, name: string, content: string) => { // Check if quota is already exceeded @@ -103,5 +110,6 @@ export const usePlaygroundActions = () => { updateInputDocument, addTransformation, reorderTransformation, + removeTransformation, }; }; From 0e446ad0f031b9e349c4f324a05ba81a6ff25e05 Mon Sep 17 00:00:00 2001 From: Mikayla Thompson Date: Tue, 29 Apr 2025 17:46:01 -0600 Subject: [PATCH 13/25] mostly working version of editors Signed-off-by: Mikayla Thompson --- frontend/next.config.ts | 8 +- .../playground/AceEditorComponent.tsx | 105 +++++++++ .../playground/OutputDocumentSection.tsx | 5 +- .../playground/TransformationItem.tsx | 112 +++++++++ .../playground/TransformationSection.tsx | 212 +++++++----------- .../playground/boardI18nStrings.tsx | 71 ++++++ .../playground/boardItemI18nStrings.tsx | 9 + frontend/src/hooks/usePlaygroundActions.ts | 14 ++ 8 files changed, 398 insertions(+), 138 deletions(-) create mode 100644 frontend/src/components/playground/AceEditorComponent.tsx create mode 100644 frontend/src/components/playground/TransformationItem.tsx create mode 100644 frontend/src/components/playground/boardI18nStrings.tsx create mode 100644 frontend/src/components/playground/boardItemI18nStrings.tsx diff --git a/frontend/next.config.ts b/frontend/next.config.ts index b3a36a3c5c..8bf8d0f7cc 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -10,11 +10,15 @@ const nextConfig: NextConfig = { "@cloudscape-design/component-toolkit" ], webpack(config) { - // h/t https://github.com/securingsincity/react-ace/issues/725#issuecomment-1407356137 + // Configure webpack to handle Ace Editor worker files config.module.rules.push({ - test: /ace-builds.*\/worker-.*$/, + test: /ace-builds.*\/worker-.*\.js$/, type: "asset/resource", + generator: { + filename: "static/workers/[name][ext]", + }, }); + return config; }, }; diff --git a/frontend/src/components/playground/AceEditorComponent.tsx b/frontend/src/components/playground/AceEditorComponent.tsx new file mode 100644 index 0000000000..b7f2a6d59f --- /dev/null +++ b/frontend/src/components/playground/AceEditorComponent.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import AceEditor, { IAnnotation } from "react-ace"; +import Box from "@cloudscape-design/components/box"; +import { usePlayground } from "@/context/PlaygroundContext"; +import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; + +// Import ace-builds core +import ace from "ace-builds"; + +// Import modes +import "ace-builds/src-noconflict/mode-json"; +import "ace-builds/src-noconflict/mode-javascript"; +import "ace-builds/src-noconflict/theme-github"; +import "ace-builds/src-noconflict/ext-language_tools"; + +// Import workers +import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json"; +import javascriptWorkerUrl from "ace-builds/src-noconflict/worker-javascript"; + +// Configure Ace to use the imported workers +ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl); +ace.config.setModuleUrl("ace/mode/javascript_worker", javascriptWorkerUrl); + +interface AceEditorComponentProps { + itemId: string; + mode?: "json" | "javascript"; +} + +export default function AceEditorComponent({ + itemId, + mode = "json", +}: Readonly) { + const { state } = usePlayground(); + const { updateTransformation } = usePlaygroundActions(); + const [content, setContent] = useState(""); + const [validationErrors, setValidationErrors] = useState([]); + const isUpdatingRef = useRef(false); + + // Find the transformation by ID + const transformation = state.transformations.find((t) => t.id === itemId); + + // Initialize content from the transformation + useEffect(() => { + // Skip if we're in the middle of updating + if (isUpdatingRef.current) { + return; + } + + if (transformation) { + setContent(transformation.content || ""); + } + }, [transformation]); + + // Handle content change and save (debounce is handled internally by AceEditor) + const handleChange = (newContent: string) => { + setContent(newContent); + + // Skip update if transformation doesn't exist + if (!transformation) { + return; + } + + // Skip update if content is the same + if (transformation.content === newContent) { + return; + } + + // Only save if there are no validation errors + if (validationErrors.length === 0) { + // Set flag to prevent re-initialization from the useEffect + isUpdatingRef.current = true; + + // Update the transformation + updateTransformation(itemId, transformation.name, newContent); + + // Reset flag after a short delay to allow state to settle + setTimeout(() => { + isUpdatingRef.current = false; + }, 100); + } + }; + + return ( + + { + setValidationErrors(errors as IAnnotation[]); + }} + name={itemId} + debounceChangePeriod={100} + width="100%" + editorProps={{ $blockScrolling: true }} + setOptions={{ + enableBasicAutocompletion: true, + }} + /> + + ); +} diff --git a/frontend/src/components/playground/OutputDocumentSection.tsx b/frontend/src/components/playground/OutputDocumentSection.tsx index 3c216b52bf..a5ee8b045d 100644 --- a/frontend/src/components/playground/OutputDocumentSection.tsx +++ b/frontend/src/components/playground/OutputDocumentSection.tsx @@ -3,6 +3,7 @@ import Header from "@cloudscape-design/components/header"; import SpaceBetween from "@cloudscape-design/components/space-between"; import Box from "@cloudscape-design/components/box"; import { usePlayground } from "@/context/PlaygroundContext"; +import { DocumentItemWithPopoverCodeView } from "./DocumentItemWithPopoverCodeView"; // Inner component that uses the usePlayground hook export default function OutputDocumentSection() { @@ -14,7 +15,9 @@ export default function OutputDocumentSection() { {state.outputDocuments.length === 0 ? ( No output documents. ) : ( - state.outputDocuments.map((doc) => {doc.name}) + state.outputDocuments.map((doc) => ( + + )) )} diff --git a/frontend/src/components/playground/TransformationItem.tsx b/frontend/src/components/playground/TransformationItem.tsx new file mode 100644 index 0000000000..172aabb466 --- /dev/null +++ b/frontend/src/components/playground/TransformationItem.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import BoardItem from "@cloudscape-design/board-components/board-item"; +import Header from "@cloudscape-design/components/header"; +import Button from "@cloudscape-design/components/button"; +import SpaceBetween from "@cloudscape-design/components/space-between"; +import Input from "@cloudscape-design/components/input"; +import { BoardProps } from "@cloudscape-design/board-components/board"; +import { Transformation } from "@/context/PlaygroundContext"; +import { boardItemI18nStrings } from "./boardItemI18nStrings"; +import AceEditorComponent from "./AceEditorComponent"; +import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; + +interface TransformationItemProps { + item: BoardProps.Item; + onRemove: (id: string) => void; +} + +export default function TransformationItem({ + item, + onRemove, +}: TransformationItemProps) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(item.data.name); + const { updateTransformation } = usePlaygroundActions(); + + return ( + +