Skip to content

Commit

Permalink
refactor(ui): code editor state refactor (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
mike-winberry authored Feb 13, 2025
1 parent 80449a6 commit d4e1adf
Show file tree
Hide file tree
Showing 10 changed files with 158 additions and 115 deletions.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"predev": "npm run build:wasm && npm run generate:types",
"dev": "next dev --turbopack",
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
Expand Down Expand Up @@ -35,7 +35,8 @@
"prismjs": "^1.29.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-svg": "^16.3.0"
"react-svg": "^16.3.0",
"prettier": "^3.5.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
Expand All @@ -53,7 +54,6 @@
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.5.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
Expand Down
9 changes: 8 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defineConfig } from '@playwright/test';
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
Expand All @@ -10,6 +10,13 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
},

projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],

reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'playwright-report/report.json' }],
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

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

130 changes: 45 additions & 85 deletions src/components/CodeEditor/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import React, { useCallback, useEffect, useRef, useState, memo } from 'react';
import { Box, useMediaQuery, Fab } from '@mui/material';
import { tokyoNight } from '@uiw/codemirror-theme-tokyo-night';
import { tokyoNightDay } from '@uiw/codemirror-theme-tokyo-night-day';
import CodeMirror from '@uiw/react-codemirror';
import { basicSetup } from '@uiw/codemirror-extensions-basic-setup';
import { Box, useMediaQuery, Fab } from '@mui/material';
import { UploadedFile } from '@/lib/types/UploadedFile';
import { langs } from '@uiw/codemirror-extensions-langs';
import { tokyoNight } from '@uiw/codemirror-theme-tokyo-night';
import { basicSetup } from '@uiw/codemirror-extensions-basic-setup';
import { useFileValidation } from '@/context/FileValidationContext';
import * as prettier from 'prettier/standalone.js';
import babelPlugin from 'prettier/plugins/babel.js';
import estreePlugin from 'prettier/plugins/estree.js';
import yamlPlugin from 'prettier/plugins/yaml.js';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { tokyoNightDay } from '@uiw/codemirror-theme-tokyo-night-day';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import React, { useCallback, useEffect, useRef, useState, memo } from 'react';
import './CodeEditor.css';
import { ValidationResult } from '@/lib/types/gen';

// Memoizes the CodeMirror component to prevent unnecessary re-renders
const MemoizedCodeMirror = memo(CodeMirror, (prevProps, nextProps) => {
Expand All @@ -27,89 +24,48 @@ const CodeEditor = () => {
const isSmallScreen = useMediaQuery((theme) => theme.breakpoints.down('md'));

// States
const [content, setContent] = useState('');
const [fileExtension, setFileExtension] = useState('');
const [validationResult, setValidationResult] = useState('');
const [scrollAnchor, setScrollAnchor] = useState<'top' | 'bottom'>('bottom');
// Refs
const previousFileRef = useRef<UploadedFile | undefined>(undefined);
const debounceRef = useRef<NodeJS.Timeout | null>(null);

// runs prettier and sets the file extension and validation result if prettier fails
const prettify = useCallback(
async (content: string, name: string) => {
let fileExtension = '';
let result = '';
let validationResult = '';
try {
fileExtension = name.includes('json') ? 'json' : 'yaml';
result = await prettier.format(content, {
parser: fileExtension,
plugins: [babelPlugin, estreePlugin, yamlPlugin],
indentStyle: 'space',
indentWidth: 5,
});
} catch (error) {
if (error instanceof Error) {
validationResult = JSON.stringify(error.message, null, 2);
result = content;
}
}
setFileExtension(fileExtension);
setValidationResult(validationResult);
return result;
},
[setFileExtension, setValidationResult]
);

// Runs when the selectedFile changes
useEffect(() => {
// Prettifies the content when the selectedFile changes
if (selectedFile?.content !== undefined && selectedFile.content !== previousFileRef.current?.content) {
prettify(selectedFile?.content || '', selectedFile?.name || '').then((content) => {
setContent(content);
});
}
}, [selectedFile, prettify, handleValidate]);
const previousFileRef = useRef<UploadedFile | null>(null);
const debouncedUpdateContentRef = useRef<NodeJS.Timeout | null>(null);

// Validates the content when the selectedFile changes
useEffect(() => {
if (!validating && selectedFile && selectedFile.content !== previousFileRef.current?.content) {
handleValidate();
if (!validating && selectedFile?.content !== previousFileRef.current?.content) {
previousFileRef.current = selectedFile;
handleValidate().then((validationResult: ValidationResult) => {
updateFile({
...selectedFile,
validationResult,
});
});
}
}, [selectedFile, handleValidate, validating]);
}, [selectedFile, handleValidate, validating, updateFile]);

// Sets the validation result when the selectedFile validationResult changes
useEffect(() => {
if (selectedFile?.validationResult) {
setValidationResult(JSON.stringify(selectedFile?.validationResult, null, 2));
const stringifiedValidationResult = JSON.stringify(selectedFile?.validationResult, null, 2);
if (stringifiedValidationResult !== validationResult) {
setValidationResult(stringifiedValidationResult);
}
}, [selectedFile]);
}, [selectedFile, validationResult]);

// Debounces the content update
useEffect(() => {
// If the debounceRef is not null or the validating state is true, clear the timeout
// to prevent another validation from running while the first one is still running
if (debounceRef.current !== null || validating === true) {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
}
// Debounces the content update
debounceRef.current = setTimeout(() => {
updateFile({
...selectedFile,
content: content,
});
}, 1000);

return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
const debouncedUpdateContent = useCallback(
(value: string) => {
if (debouncedUpdateContentRef.current) {
clearTimeout(debouncedUpdateContentRef.current);
}
};
}, [content, validating, updateFile, selectedFile]);
debouncedUpdateContentRef.current = setTimeout(() => {
if (selectedFile) {
updateFile({
...selectedFile,
content: value,
});
}
}, 1000);
},
[selectedFile, updateFile]
);

const scrollTo = (position: 'top' | 'bottom') => {
const scrollContainer = document.getElementById(position === 'top' ? 'scroll-anchor-top' : 'scroll-anchor-bottom');
Expand All @@ -133,25 +89,29 @@ const CodeEditor = () => {
}}
>
<div id="scroll-anchor-top" />
{fileExtension === 'json' ? (
{selectedFile?.extension === 'json' ? (
<MemoizedCodeMirror
value={content}
value={selectedFile?.content}
id="code-editor-display"
data-testid="code-editor-display"
theme={isDark ? tokyoNight : tokyoNightDay}
extensions={[basicSetup(), langs.json()]}
onChange={(value) => {
setContent(value);
if (previousFileRef.current) {
debouncedUpdateContent(value);
}
}}
/>
) : (
<MemoizedCodeMirror
value={content}
value={selectedFile?.content}
data-testid="code-editor-display"
theme={isDark ? tokyoNight : tokyoNightDay}
extensions={[basicSetup(), langs.yaml()]}
onChange={(value) => {
setContent(value);
if (previousFileRef.current) {
debouncedUpdateContent(value);
}
}}
/>
)}
Expand Down
3 changes: 2 additions & 1 deletion src/context/FileValidationContext.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { UploadedFile } from '@/lib/types/UploadedFile';
import useFileManager from '@/hooks/useFileManager';
import { ValidationResult } from '@/lib/types/gen';

interface FileValidationContextProps {
handleValidate: () => void;
handleValidate: () => Promise<ValidationResult>;
uploading: boolean;
validating: boolean;
files: UploadedFile[];
Expand Down
36 changes: 31 additions & 5 deletions src/hooks/__tests__/useFileUpload.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import useFileManager from '../useFileManager';

// Mock the prettify function
jest.mock('../../lib/utils', () => ({
prettify: jest.fn((content: string) => {
return Promise.resolve({ result: content, formatError: undefined }); // Mocked response
}),
}));

// Mock the FileReader
global.FileReader = class {
static EMPTY = 0;
static LOADING = 1;
static DONE = 2;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onload: any;
readAsText = jest.fn(() => {
this.onload({ target: { result: 'test' } }); // Simulate file read
});
} as unknown as typeof FileReader;

describe('useFileUpload', () => {
it('should return the correct values', () => {
const { result } = renderHook(() => useFileManager());
Expand All @@ -10,14 +29,21 @@ describe('useFileUpload', () => {

it('should handle file change', async () => {
const { result } = renderHook(() => useFileManager());
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
const file = new File(['test'], 'test.json', { type: 'application/json' });

await act(async () => {
result.current.handleFileUpload({ target: { files: [file] } } as unknown as React.ChangeEvent<HTMLInputElement>);
await waitFor(() => result.current.selectedFile !== undefined);
result.current.handleFileUpload({
target: { files: [file] },
} as unknown as React.ChangeEvent<HTMLInputElement>);
await waitFor(() => result.current.selectedFile !== null);
});
expect(result.current.selectedFile).toEqual({
content: 'test',
name: 'test.json',
file,
extension: 'json',
validationResult: undefined,
});

expect(result.current.selectedFile).toEqual({ content: 'test', name: 'test.txt', file });
});

it('should handle file change with no file', () => {
Expand Down
25 changes: 12 additions & 13 deletions src/hooks/useFileManager.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ValidationResult } from '@/lib/types/gen';
import { UploadedFile } from '@/lib/types/UploadedFile';
import { prettify } from '@/lib/utils';
import { useCallback, useState } from 'react';

function useFileManager() {
Expand Down Expand Up @@ -41,10 +42,13 @@ function useFileManager() {
reader.onload = async (event) => {
const fileContent = event.target?.result;
if (typeof fileContent === 'string') {
const { result, formatError } = await prettify(fileContent, file.name.includes('json') ? 'json' : 'yaml');
addFile({
file,
content: fileContent,
content: result,
name: file.name,
extension: file.name.includes('json') ? 'json' : 'yaml',
validationResult: formatError ? { prettier: formatError } : undefined,
});
}
setUploading(false);
Expand All @@ -55,9 +59,10 @@ function useFileManager() {
}
}

const handleValidate = useCallback(async () => {
if (!selectedFile) return;
const handleValidate = useCallback(async (): Promise<ValidationResult> => {
if (!selectedFile) return { error: 'No file selected' };
setValidating(true);
let result: ValidationResult;
try {
const formData = new FormData();
formData.append('data', selectedFile.content || '');
Expand All @@ -66,20 +71,14 @@ function useFileManager() {
body: formData,
cache: 'no-store',
});
const result = await response.json();
updateFile({
...selectedFile,
validationResult: result as ValidationResult,
});
result = await response.json();
} catch (error) {
updateFile({
...selectedFile,
validationResult: { error: error instanceof Error ? error.message : 'Unknown error' },
});
result = { error: error instanceof Error ? error.message : 'Unknown error' };
} finally {
setValidating(false);
}
}, [selectedFile, updateFile]);
return result;
}, [selectedFile]);

return {
files,
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/UploadedFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export type UploadedFile = {
path?: string;
name?: string;
validationResult?: ValidationResult;
extension?: 'json' | 'yaml';
};
Loading

0 comments on commit d4e1adf

Please sign in to comment.