diff --git a/frontend/__tests__/components/playground/SaveStatusIndicator.test.tsx b/frontend/__tests__/components/playground/SaveStatusIndicator.test.tsx new file mode 100644 index 0000000000..7990244573 --- /dev/null +++ b/frontend/__tests__/components/playground/SaveStatusIndicator.test.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import SaveStatusIndicator from "@/components/playground/SaveStatusIndicator"; +import { SaveStatus } from "@/types/SaveStatus"; +import { formatDistanceToNow } from "date-fns"; +import { IAnnotation } from "react-ace/lib/types"; + +// Make mocks for the external libraries +jest.mock("date-fns", () => ({ + formatDistanceToNow: jest.fn(), +})); +jest.mock("@cloudscape-design/components/status-indicator", () => { + return { + __esModule: true, + default: ({ + type, + children, + }: { + type: string; + children: React.ReactNode; + }) => ( +
+ {children} +
+ ), + }; +}); + +describe("SaveStatusIndicator", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("status indicator display based off of state", () => { + it("should render with success styling when status is SAVED", () => { + const savedState = { + status: SaveStatus.SAVED, + savedAt: null, + errors: [], + }; + + render(); + const statusIndicator = screen.getByTestId("status-indicator"); + + expect(statusIndicator).toBeInTheDocument(); + expect(statusIndicator).toHaveAttribute("data-type", "success"); + expect(statusIndicator).toHaveTextContent("Saved"); + }); + + it("should render with error styling when status is BLOCKED", () => { + const blockedState = { + status: SaveStatus.BLOCKED, + savedAt: null, + errors: [], + }; + + render(); + const statusIndicator = screen.getByTestId("status-indicator"); + + expect(statusIndicator).toBeInTheDocument(); + expect(statusIndicator).toHaveAttribute("data-type", "error"); + expect(statusIndicator).toHaveTextContent("Blocked"); + }); + + it("should render with in-progress styling when status is UNSAVED", () => { + const unsavedState = { + status: SaveStatus.UNSAVED, + savedAt: null, + errors: [], + }; + + render(); + const statusIndicator = screen.getByTestId("status-indicator"); + + expect(statusIndicator).toBeInTheDocument(); + expect(statusIndicator).toHaveAttribute("data-type", "in-progress"); + expect(statusIndicator).toHaveTextContent("Unsaved"); + }); + + it("should default to in-progress styling when state is undefined", () => { + // @ts-ignore - Intentionally passing undefined for testing + render(); + const statusIndicator = screen.getByTestId("status-indicator"); + + expect(statusIndicator).toBeInTheDocument(); + expect(statusIndicator).toHaveAttribute("data-type", "in-progress"); + expect(statusIndicator).toHaveTextContent("Unsaved"); + }); + + it("should default to in-progress styling when state.status is null", () => { + const nullState = { + status: null, + savedAt: null, + errors: [], + }; + + // @ts-ignore - Intentionally passing null status for testing + render(); + const statusIndicator = screen.getByTestId("status-indicator"); + + expect(statusIndicator).toBeInTheDocument(); + expect(statusIndicator).toHaveAttribute("data-type", "in-progress"); + expect(statusIndicator).toHaveTextContent("Unsaved"); + }); + }); + + describe("save status indicator displaying time formatting", () => { + it("should display the correct time format when savedAt is provided", () => { + const mockTime = "5 minutes"; + (formatDistanceToNow as jest.Mock).mockReturnValue(mockTime); + + const savedDate = new Date(); + const savedState = { + status: SaveStatus.SAVED, + savedAt: savedDate, + errors: [], + }; + + render(); + + expect(formatDistanceToNow).toHaveBeenCalledWith(savedDate); + expect(screen.getByText(`Saved ${mockTime} ago`)).toBeInTheDocument(); + }); + + it("should not display time when savedAt is null", () => { + const savedState = { + status: SaveStatus.SAVED, + savedAt: null, + errors: [], + }; + + render(); + + expect(formatDistanceToNow).not.toHaveBeenCalled(); + expect(screen.getByText("Saved")).toBeInTheDocument(); + expect(screen.queryByText(/ago/)).not.toBeInTheDocument(); + }); + }); + + describe("save status indicator displaying error messages", () => { + it("should display correct message with single error", () => { + const blockedState = { + status: SaveStatus.BLOCKED, + savedAt: null, + errors: [ + { row: 1, column: 1, type: "error" as const, text: "Test error" }, + ] as IAnnotation[], + }; + + render(); + + expect(screen.getByText("Blocked (1 error)")).toBeInTheDocument(); + }); + + it("should display correct message with multiple errors", () => { + const blockedState = { + status: SaveStatus.BLOCKED, + savedAt: null, + errors: [ + { row: 1, column: 1, type: "error" as const, text: "First error" }, + { row: 2, column: 1, type: "error" as const, text: "Second error" }, + { row: 3, column: 1, type: "error" as const, text: "Third error" }, + ] as IAnnotation[], + }; + + render(); + + expect(screen.getByText("Blocked (3 errors)")).toBeInTheDocument(); + }); + + it("should display only 'Blocked' when there are no errors", () => { + const blockedState = { + status: SaveStatus.BLOCKED, + savedAt: null, + errors: [], + }; + + render(); + + expect(screen.getByText("Blocked")).toBeInTheDocument(); + expect(screen.queryByText(/\(\d+ errors?\)/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/hooks/usePlaygroundActions.test.tsx b/frontend/__tests__/hooks/usePlaygroundActions.test.tsx index cc9d5bb0a9..2625eab61b 100644 --- a/frontend/__tests__/hooks/usePlaygroundActions.test.tsx +++ b/frontend/__tests__/hooks/usePlaygroundActions.test.tsx @@ -109,11 +109,13 @@ describe("usePlaygroundActions", () => { const { result } = renderHook(() => usePlaygroundActions(), { wrapper }); // Adding a document should throw an error - expect(() => { - act(() => { - result.current.addInputDocument(TEST_DOC_NAME, TEST_DOC_CONTENT); - }); - }).toThrow(/exceed the maximum storage limit/); + const addDocumentAction = () => { + result.current.addInputDocument(TEST_DOC_NAME, TEST_DOC_CONTENT); + }; + + expect(() => act(addDocumentAction)).toThrow( + /exceed the maximum storage limit/ + ); // Reset the mock (getJsonSizeInBytes as jest.Mock).mockReturnValue(1024); 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/package-lock.json b/frontend/package-lock.json index 54c2083e05..55a15e6e2c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,8 +15,10 @@ "@cloudscape-design/test-utils-core": "^1.0.56", "@types/uuid": "^10.0.0", "ace-builds": "^1.40.1", + "date-fns": "^4.1.0", "next": "15.3.0", "react": "^18.3.1", + "react-ace": "^14.0.1", "react-dom": "^18.3.1", "uuid": "^11.1.0" }, @@ -682,6 +684,22 @@ "react": ">=16.8.0" } }, + "node_modules/@cloudscape-design/components/node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/@cloudscape-design/components/node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -4257,19 +4275,13 @@ } }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/debug": { @@ -4405,6 +4417,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 +7798,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 +8765,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..e4e80afad0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,8 +19,10 @@ "@cloudscape-design/test-utils-core": "^1.0.56", "@types/uuid": "^10.0.0", "ace-builds": "^1.40.1", + "date-fns": "^4.1.0", "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/AceEditorComponent.tsx b/frontend/src/components/playground/AceEditorComponent.tsx new file mode 100644 index 0000000000..55c665a0af --- /dev/null +++ b/frontend/src/components/playground/AceEditorComponent.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import AceEditor, { IAnnotation } from "react-ace"; +import { usePlayground } from "@/context/PlaygroundContext"; +import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; +import { SaveState, SaveStatus } from "@/types/SaveStatus"; + +// Import ace-builds core +import ace from "ace-builds"; +import beautify from "ace-builds/src-noconflict/ext-beautify"; + +// 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"; +// This seems like it should be imported from the line above, but there are missing values (like showing highlighted text) without this +import "ace-builds/css/theme/github.css"; + +// Import workers +import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json"; +import javascriptWorkerUrl from "ace-builds/src-noconflict/worker-javascript"; +import { defaultContent } from "./DefaultTransformationContent"; + +// 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"; + formatRef: React.RefObject<(() => void) | null>; + onSaveStatusChange: (status: SaveState) => void; +} + +export default function AceEditorComponent({ + itemId, + mode, + formatRef, + onSaveStatusChange, +}: Readonly) { + const { state } = usePlayground(); + const { updateTransformation } = usePlaygroundActions(); + const [content, setContent] = useState(""); + + // Use a ref instead of state for validation errors to prevent re-renders + const validationErrorsRef = useRef([]); + + // Refs and dimension state used for resizing the AceEditor instance + const editorRef = useRef(null); + const containerRef = useRef(null); + const [dimensions, setDimensions] = useState({ width: 500, height: 300 }); + + // Find the transformation by ID + const transformation = state.transformations.find((t) => t.id === itemId); + + // Set up ResizeObserver to monitor container size + useEffect(() => { + if (!containerRef.current) return; + + const resizeObserver = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + // Subtract some padding for better appearance + setDimensions({ + width: Math.max(width - 20, 100), // Ensure minimum width + height: Math.max(height - 20, 100), // Ensure minimum height + }); + }); + + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + + // Initialize content from the transformation + useEffect(() => { + if (transformation) { + setContent(transformation.content || defaultContent); + onSaveStatusChange({ + status: SaveStatus.SAVED, + savedAt: new Date(Date.now()), + errors: [], + }); + } + }, [transformation, onSaveStatusChange]); + + // Save the current content to local storage + const saveContent = useCallback(() => { + // Immediately set content to the exact content from the editor ref (our state may be stale) + const editorContent = editorRef.current?.editor.getValue(); + const activeContent = editorContent ?? content; + // Skip update if transformation doesn't exist or if content is unchanged. + if (!transformation || activeContent === transformation.content) return; + + // Check for validation errors + if (validationErrorsRef.current.length > 0) { + onSaveStatusChange({ + status: SaveStatus.BLOCKED, + savedAt: null, + errors: validationErrorsRef.current, + }); + return; + } + + updateTransformation(itemId, transformation.name, activeContent); + onSaveStatusChange({ + status: SaveStatus.SAVED, + savedAt: new Date(Date.now()), + errors: [], + }); + }, [ + content, + itemId, + transformation, + updateTransformation, + onSaveStatusChange, + editorRef, + ]); + + // Format the code based on the mode + const formatCode = useCallback(() => { + if (!content) return; + try { + if (editorRef.current) { + beautify.beautify(editorRef.current.editor.session); + } + } catch (error) { + console.error("Error formatting code:", error); + } + saveContent(); + }, [content, saveContent]); + + // Handle keyboard shortcuts + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + // Check for Ctrl+S or Cmd+S + if ((event.ctrlKey || event.metaKey) && event.key === "s") { + event.preventDefault(); + saveContent(); + } + }, + [saveContent], + ); + + // Add keyboard event listener + useEffect(() => { + const editor = editorRef.current?.editor; + if (editor) { + editor.container.addEventListener("keydown", handleKeyDown); + } + + return () => { + if (editor) { + editor.container.removeEventListener("keydown", handleKeyDown); + } + }; + }, [handleKeyDown]); + + // Handle content change and save (debounce is handled internally by AceEditor) + const handleChange = (newContent: string) => { + if (newContent === content) return; // Skip if content is unchanged + setContent(newContent); + + // Skip update if transformation doesn't exist + if (!transformation) { + return; + } + + // Check for validation errors + if (validationErrorsRef.current.length > 0) { + onSaveStatusChange({ + status: SaveStatus.BLOCKED, + savedAt: null, + errors: validationErrorsRef.current, + }); + } else { + // Mark as unsaved if content is different from saved content + const isSaved = transformation.content === newContent; + if (!isSaved) { + onSaveStatusChange({ + status: SaveStatus.UNSAVED, + savedAt: null, + errors: [], + }); + } + } + + // Auto-save after debounce period (handled by AceEditor) + saveContent(); + }; + + // Expose formatCode function to parent component via ref + useEffect(() => { + formatRef.current = formatCode; + }, [formatCode, formatRef]); + + return ( +
+ { + // The UI gets "twitchy" if we set state (and therefore re-render) on every validation + // So we're using a ref to store the errors instead + validationErrorsRef.current = errors as IAnnotation[]; + }} + name={itemId} + debounceChangePeriod={500} + width={`${dimensions.width}px`} + height={`${dimensions.height}px`} + editorProps={{ $blockScrolling: false }} + setOptions={{ + enableBasicAutocompletion: true, + }} + showGutter={true} + showPrintMargin={false} + highlightActiveLine={true} + minLines={10} + tabSize={2} + commands={beautify.commands.map((command) => ({ + name: command.name, + bindKey: + typeof command.bindKey === "string" + ? { win: command.bindKey, mac: command.bindKey } + : command.bindKey, + exec: command.exec, + }))} + /> +
+ ); +} diff --git a/frontend/src/components/playground/DefaultTransformationContent.tsx b/frontend/src/components/playground/DefaultTransformationContent.tsx new file mode 100644 index 0000000000..7486a36859 --- /dev/null +++ b/frontend/src/components/playground/DefaultTransformationContent.tsx @@ -0,0 +1,9 @@ +export const defaultContent: string = `function main(context) { + return (document) => { + // Your transformation logic here + return document; + }; +} +// Entrypoint function +(() => main)(); +`; 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/SaveStatusIndicator.tsx b/frontend/src/components/playground/SaveStatusIndicator.tsx new file mode 100644 index 0000000000..d672074661 --- /dev/null +++ b/frontend/src/components/playground/SaveStatusIndicator.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useReducer } from "react"; +import { SaveStatus, SaveState } from "@/types/SaveStatus"; +import StatusIndicator from "@cloudscape-design/components/status-indicator"; + +import { formatDistanceToNow } from "date-fns"; + +/** + * Component to display the save status of a document or transformation + */ +interface SaveStatusIndicatorProps { + state: SaveState; +} + +/** + * A component that displays a status indicator for the save state of a document or transformation + * + * @param status - The current save status (SAVED, UNSAVED, or BLOCKED) + */ +export default function SaveStatusIndicator({ + state, +}: Readonly) { + // Use useReducer to force re-renders of the "X seconds ago" text without causing sonarqube + // to complain about unused variables + const forceUpdate = useReducer((tick) => tick + 1, 0)[1]; + + useEffect(() => { + const intervalId = setInterval(() => { + forceUpdate(); + }, 1000 * 30); // 30 seconds + + // Clean up on unmount + return () => clearInterval(intervalId); + }, [forceUpdate]); + + const getStatusType = (state: SaveState) => { + switch (state?.status ?? null) { + case SaveStatus.SAVED: + return "success"; + case SaveStatus.BLOCKED: + return "error"; + case SaveStatus.UNSAVED: + default: + return "in-progress"; + } + }; + + const getStatusText = (state: SaveState) => { + switch (state?.status ?? null) { + case SaveStatus.SAVED: + return ( + "Saved" + + (state.savedAt ? ` ${formatDistanceToNow(state.savedAt)} ago` : "") + ); + case SaveStatus.BLOCKED: { + const errorCount = state.errors.length; + const errorSuffix = errorCount > 1 ? "s" : ""; + const errorText = errorCount + ? ` (${errorCount} error${errorSuffix})` + : ""; + return "Blocked" + errorText; + } + case SaveStatus.UNSAVED: + default: + return "Unsaved"; + } + }; + + return ( + + {getStatusText(state)} + + ); +} diff --git a/frontend/src/components/playground/TransformationBoardItemStrings.tsx b/frontend/src/components/playground/TransformationBoardItemStrings.tsx new file mode 100644 index 0000000000..8cc8323f8d --- /dev/null +++ b/frontend/src/components/playground/TransformationBoardItemStrings.tsx @@ -0,0 +1,8 @@ +export const transformationBoardItemStrings = { + 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.", +}; diff --git a/frontend/src/components/playground/TransformationBoardLayoutStrings.tsx b/frontend/src/components/playground/TransformationBoardLayoutStrings.tsx new file mode 100644 index 0000000000..ea0e3f3097 --- /dev/null +++ b/frontend/src/components/playground/TransformationBoardLayoutStrings.tsx @@ -0,0 +1,78 @@ +"use client"; +import { BoardProps } from "@cloudscape-design/board-components"; +import { Transformation } from "@/context/PlaygroundContext"; + +export const transformationBoardLayoutStrings: BoardProps.I18nStrings = + (() => { + function createAnnouncement( + operationAnnouncement: string, + conflicts: readonly BoardProps.Item[], + disturbed: readonly BoardProps.Item[], + ) { + const conflictsAnnouncement = + conflicts.length > 0 + ? `Conflicts with ${conflicts.map((c) => c.data.name).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.name}.`, + [], + op.disturbed, + ), + navigationAriaLabel: "Board navigation", + navigationAriaDescription: "Click on non-empty item to move focus over", + navigationItemAriaLabel: (item) => (item ? item.data.name : "Empty"), + }; + })(); diff --git a/frontend/src/components/playground/TransformationItem.tsx b/frontend/src/components/playground/TransformationItem.tsx new file mode 100644 index 0000000000..13765ac805 --- /dev/null +++ b/frontend/src/components/playground/TransformationItem.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { SaveState, SaveStatus } from "@/types/SaveStatus"; +import BoardItem from "@cloudscape-design/board-components/board-item"; +import Header from "@cloudscape-design/components/header"; +import Button, { ButtonProps } 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 { transformationBoardItemStrings } from "./TransformationBoardItemStrings"; +import AceEditorComponent from "./AceEditorComponent"; +import { usePlaygroundActions } from "@/hooks/usePlaygroundActions"; +import SaveStatusIndicator from "@/components/playground/SaveStatusIndicator"; + +interface TransformationItemProps { + item: BoardProps.Item; + onRemove: (id: string) => void; +} + +export default function TransformationItem({ + item, + onRemove, +}: Readonly) { + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(item.data.name); + const [saveStatus, setSaveStatus] = useState({ + status: SaveStatus.UNSAVED, + savedAt: null, + errors: [], + }); + const { updateTransformation } = usePlaygroundActions(); + const formatCodeRef = useRef<(() => void) | null>(null); + + const handleSaveStatusChange = useCallback((status: SaveState) => { + setSaveStatus(status); + }, []); + + const handleEditNameChange = ( + event: CustomEvent, + ) => { + event.preventDefault(); + event.stopPropagation(); + + if (isEditing) { + if (editName.trim()) { + updateTransformation(item.id, editName.trim(), item.data.content); + } else { + setEditName(item.data.name); // Reset to original if empty + } + + // Exit edit mode + setIsEditing(false); + } else { + // Enter edit mode + setEditName(item.data.name); + setIsEditing(true); + } + }; + + return ( + + + ({ + id: transform.id, + rowSpan: itemDimensions[transform.id]?.rowSpan ?? 1, + columnSpan: 4, // Always full width + data: transform, + }))} + i18nStrings={transformationBoardLayoutStrings} + onItemsChange={handleItemsChange} + renderItem={(item: BoardProps.Item) => ( + + )} + empty={ + + + + No items + + + + } + /> + + + ); diff --git a/frontend/src/hooks/usePlaygroundActions.ts b/frontend/src/hooks/usePlaygroundActions.ts index d7d167886f..152fdff51c 100644 --- a/frontend/src/hooks/usePlaygroundActions.ts +++ b/frontend/src/hooks/usePlaygroundActions.ts @@ -71,6 +71,26 @@ export const usePlaygroundActions = () => { [dispatch], ); + const removeTransformation = useCallback( + (id: string) => { + dispatch({ type: "REMOVE_TRANSFORMATION", payload: id }); + }, + [dispatch], + ); + + const updateTransformation = useCallback( + (id: string, name: string, content: string) => { + const updatedTransform: Transformation = { + id, + name, + content, + }; + dispatch({ type: "UPDATE_TRANSFORMATION", payload: updatedTransform }); + return updatedTransform; + }, + [dispatch], + ); + const updateInputDocument = useCallback( (id: string, name: string, content: string) => { // Check if quota is already exceeded @@ -103,5 +123,7 @@ export const usePlaygroundActions = () => { updateInputDocument, addTransformation, reorderTransformation, + removeTransformation, + updateTransformation, }; }; diff --git a/frontend/src/types/SaveStatus.ts b/frontend/src/types/SaveStatus.ts new file mode 100644 index 0000000000..68abd30b5b --- /dev/null +++ b/frontend/src/types/SaveStatus.ts @@ -0,0 +1,13 @@ +import { IAnnotation } from "react-ace/lib/types"; + +export enum SaveStatus { + SAVED = "saved", + UNSAVED = "unsaved", + BLOCKED = "blocked", +} + +export type SaveState = { + status: SaveStatus; + savedAt: Date | null; + errors: IAnnotation[]; +};