Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7c08eef
Basic file uploading
mikaylathompson Apr 26, 2025
f518b71
Improve error checking/display
mikaylathompson Apr 26, 2025
b06d1b9
Add code view popover
mikaylathompson Apr 26, 2025
35eba9d
format content before displaying
mikaylathompson Apr 26, 2025
60ab771
Add eslint rule and fixes for relative imports
mikaylathompson Apr 26, 2025
2e2bdbc
Refactor
mikaylathompson Apr 26, 2025
769b07c
Add delete option
mikaylathompson Apr 26, 2025
b3fa40e
Add tests
mikaylathompson Apr 26, 2025
9b52d58
Change ndjson handling (separate docs), update tests
mikaylathompson Apr 26, 2025
deed872
Deal with size limits for local storage
mikaylathompson Apr 26, 2025
592f33c
Add editing feature
mikaylathompson Apr 26, 2025
140748c
rough draft of transformation panel
mikaylathompson Apr 29, 2025
0e446ad
mostly working version of editors
mikaylathompson Apr 29, 2025
35f0be2
Fix the twitchiness and invisible highlighting
mikaylathompson Apr 30, 2025
51214ec
Formatting, save status, etc.
mikaylathompson Apr 30, 2025
7c5f98d
Handle BoardItem resizing for the editor
mikaylathompson Apr 30, 2025
91529e3
Default content, cleanup, sizing
mikaylathompson Apr 30, 2025
491ed39
Merge branch 'main' into playground-transformations
mikaylathompson Apr 30, 2025
264d068
Sonarqube fixes
mikaylathompson Apr 30, 2025
308127a
Fix editor bugs (erasing changes) and clearer error display
mikaylathompson May 1, 2025
89ca8a6
Linter fixes
mikaylathompson May 2, 2025
4ac6a2e
Add missing files
mikaylathompson May 2, 2025
28d66cd
sonarqube fixes
mikaylathompson May 2, 2025
1b3aefc
Incorporate review feedback
mikaylathompson May 2, 2025
2ee8b96
Remove all custom styles in favor of Cloudspace components
mikaylathompson May 2, 2025
1a60b63
Add SaveStatusIndicator tests
mikaylathompson May 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions frontend/__tests__/hooks/usePlaygroundActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
};
Expand Down
38 changes: 38 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
218 changes: 218 additions & 0 deletions frontend/src/components/playground/AceEditorComponent.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious if it is easy to see this break as you've been dev'ing on this component, trying to get an understanding of if we should have UX tests to verify callback/intervals being triggered correctly.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof. It's not a bad idea, but I'm truly dreading the implementation of that and a lot of this is in flux as I work on the execution step, so it feels like work that's going to change pretty dramatically. Can we postpone for now? I can create a task to track this work.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"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 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";

// 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?: (isSaved: boolean) => void;
}

const defaultContent: string = `
function main(context) {
return (document) => {
// Your transformation logic here
return document;
};
}
// Entrypoint function
(() => main)();
`;

export default function AceEditorComponent({
itemId,
mode = "json",
formatRef,
onSaveStatusChange,
}: Readonly<AceEditorComponentProps>) {
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<IAnnotation[]>([]);
const editorRef = useRef<AceEditor>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 500, height: 300 }); // Default fallback values

// 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);
if (onSaveStatusChange) {
onSaveStatusChange(true);
}
}
}, [transformation, onSaveStatusChange]);

// Save the current content to local storage
const saveContent = useCallback(() => {
if (!transformation || content === transformation.content) return;
if (validationErrorsRef.current.length > 0) {
console.log("Validation errors:", validationErrorsRef.current);
return;
}

updateTransformation(itemId, transformation.name, content);
if (onSaveStatusChange) {
onSaveStatusChange(true);
}
}, [
content,
itemId,
transformation,
updateTransformation,
onSaveStatusChange,
]);

// Format the code based on the mode
const formatCode = useCallback(() => {
if (!content) return;
try {
console.log("Formatting code...");
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect comment to mention de-bouncing :D

const handleChange = (newContent: string) => {
setContent(newContent);

// Skip update if transformation doesn't exist
if (!transformation) {
return;
}

// Mark as unsaved if content is different from saved content
const savedStatus = transformation.content === newContent;
if (onSaveStatusChange) {
onSaveStatusChange(savedStatus);
}

// Auto-save after debounce period (handled by AceEditor)
console.log("Updating transformation:", transformation.name);
saveContent();
};

// Expose formatCode function to parent component via ref
useEffect(() => {
if (formatRef) {
formatRef.current = formatCode;
}
}, [formatCode, formatRef]);

return (
<div
ref={containerRef}
style={{ width: "100%", height: "100%", minHeight: "200px" }}
>
<AceEditor
ref={editorRef}
mode={mode}
theme="github"
value={content}
onChange={handleChange}
onValidate={(errors) => {
// 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,
}))}
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -14,7 +15,9 @@ export default function OutputDocumentSection() {
{state.outputDocuments.length === 0 ? (
<Box>No output documents.</Box>
) : (
state.outputDocuments.map((doc) => <Box key={doc.id}>{doc.name}</Box>)
state.outputDocuments.map((doc) => (
<DocumentItemWithPopoverCodeView key={doc.id} document={doc} />
))
)}
</SpaceBetween>
</Container>
Expand Down
Loading
Loading