diff --git a/src/components/CSVTableEditor.tsx b/src/components/CSVTableEditor.tsx new file mode 100644 index 0000000..c3549f9 --- /dev/null +++ b/src/components/CSVTableEditor.tsx @@ -0,0 +1,228 @@ +import React, { + ChangeEvent, + MouseEvent as RMouseEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import styles from '../css/RMLMappingEditor.module.scss'; + +interface CSVTableEditorProps { + content: string; + onContentChange: (newContent: string) => void; +} + +function CSVTableEditor({ content, onContentChange }: CSVTableEditorProps) { + const table = useMemo(() => stringToTable(content), [content]); + + const [editingCell, setEditingCell] = useState< + [number, number] | undefined + >(); + const [activeInput, setActiveInput] = useState(null); + + const tableRef = useRef(null); + + const handleClickOutside = useCallback( + (e: MouseEvent) => { + const tableDims = tableRef.current?.getBoundingClientRect(); + if ( + tableDims && + (e.clientX > tableDims.x + tableDims.width || + e.clientY > tableDims.y + tableDims.height) + ) { + setEditingCell(undefined); + } + }, + [setEditingCell] + ); + + useEffect(() => { + if (activeInput) activeInput.focus(); + }, [activeInput]); + + useEffect(() => { + onContentChange(table.map((row) => row.join(',')).join('\n')); + }, [onContentChange, table]); + + useEffect(() => { + document.body.addEventListener('click', handleClickOutside); + return () => { + document.body.removeEventListener('click', handleClickOutside); + }; + }, [handleClickOutside]); + + const handleTdClick = useCallback( + (e: RMouseEvent) => { + const [row, col] = [ + parseInt(e.currentTarget.dataset.rowIndex ?? '', 10), + parseInt(e.currentTarget.dataset.colIndex ?? '', 10), + ]; + setEditingCell([row, col]); + }, + [setEditingCell] + ); + + const handleCellValueChange = useCallback( + (e: ChangeEvent) => { + const [row, col] = [ + parseInt(e.currentTarget.dataset.rowIndex ?? '', 10), + parseInt(e.currentTarget.dataset.colIndex ?? '', 10), + ]; + const value = e.currentTarget.value; + const newTable = [...table]; + newTable[row][col] = parseRawValue(value); + onContentChange(tableToString(newTable)); + }, + [onContentChange, table] + ); + + const removeRow = useCallback( + (e: RMouseEvent) => { + const rowIndex = parseInt(e.currentTarget.dataset.rowIndex || '', 10); + + const newTable = [...table]; + newTable.splice(rowIndex, 1); + onContentChange(tableToString(newTable)); + }, + [onContentChange, table] + ); + + const addRow = useCallback(() => { + const columnCount = table[0].length; + const newRow = []; + for (let i = 0; i < columnCount; i++) { + newRow.push(''); + } + onContentChange(tableToString([...table, newRow])); + }, [onContentChange, table]); + + const addColumn = useCallback(() => { + const newTable = [...table]; + for (let i = 0; i < newTable.length; ++i) { + newTable[i].push(''); + } + onContentChange(tableToString(newTable)); + }, [onContentChange, table]); + + return ( + <> + + + + {table[0].map((cellContent, index) => ( + + ))} + + + + {table.slice(1).map((row, rowIndex) => ( + + {row.map((cellContent, colIndex) => { + const correctedRowIndex = rowIndex + 1; + return ( + + ); + })} + + + ))} + +
+ {editingCell && + editingCell[0] === 0 && + editingCell[1] === index ? ( + + ) : ( + parseCellValue(cellContent) + )} +
+ {editingCell && + editingCell[0] === correctedRowIndex && + editingCell[1] === colIndex ? ( + setActiveInput(ref)} + data-row-index={correctedRowIndex} + data-col-index={colIndex} + value={parseCellValue(cellContent)} + onChange={handleCellValueChange} + /> + ) : ( + parseCellValue(cellContent) + )} + + + + +
+
+ + +
+ + ); +} + +export default CSVTableEditor; + +const stringToTable = (content: string) => { + return content.split('\n').map((row) => { + const items = ['']; + row.split('').forEach((char, index) => { + if (char === ',' && row[index - 1] !== '\\') { + items.push(''); + return; + } + items[items.length - 1] += char; + }); + return items; + }); +}; + +const parseCellValue = (cellContent: string) => { + return cellContent.replace(/\\,/g, ','); +}; + +const parseRawValue = (value: string) => { + let parsedValue = ''; + if (value) { + parsedValue = value.replace(/,/g, '\\,'); + } + return parsedValue ?? ''; +}; + +const tableToString = (table: string[][]) => + table.map((row) => row.join(',')).join('\n'); diff --git a/src/components/InputFileEditor.tsx b/src/components/InputFileEditor.tsx new file mode 100644 index 0000000..65cf85d --- /dev/null +++ b/src/components/InputFileEditor.tsx @@ -0,0 +1,41 @@ +import React, { useMemo } from 'react'; +import { InputFile } from '../contexts/InputContext'; +import CodeEditor from './CodeEditor'; +import styles from '../css/RMLMappingEditor.module.scss'; +import CSVTableEditor from './CSVTableEditor'; + +interface InputFileEditorProps { + inputFile: InputFile; + onFileContentsChange: (newContent: string) => void; +} + +function InputFileEditor({ + inputFile, + onFileContentsChange, +}: InputFileEditorProps) { + const fileType = useMemo(() => { + const fileNameParts = inputFile.name.split('.'); + return fileNameParts[fileNameParts.length - 1]; + }, [inputFile]); + + return ( + <> + {fileType === 'csv' && ( + + )} + {fileType !== 'csv' && ( + + )} + + ); +} + +export default InputFileEditor; diff --git a/src/components/InputPanel.tsx b/src/components/InputPanel.tsx index 6798aaa..3617533 100644 --- a/src/components/InputPanel.tsx +++ b/src/components/InputPanel.tsx @@ -1,14 +1,15 @@ import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import InputContext, { INPUT_TYPES } from '../contexts/InputContext'; +import InputContext from '../contexts/InputContext'; import styles from '../css/RMLMappingEditor.module.scss'; -import CodeEditor from './CodeEditor'; +// import CodeEditor from './CodeEditor'; import { ReactComponent as PlusIcon } from '../images/plus.svg'; import { ReactComponent as DownArrow } from '../images/down-arrow.svg'; +import InputFileEditor from './InputFileEditor'; const views = { inputs: 'Input Files', // functions: 'Functions', -} +}; export interface InputPanelProps { addNewInput: () => void; @@ -17,45 +18,59 @@ export interface InputPanelProps { function InputPanel({ addNewInput }: InputPanelProps) { const [view, setView] = useState(views.inputs); const { inputFiles, setInputFiles } = useContext(InputContext); - const [selectedInputFileIndex, setSelectedInputFileIndex] = useState(); + const [selectedInputFileIndex, setSelectedInputFileIndex] = + useState(); const selectedInputFile = useMemo( - () => selectedInputFileIndex !== undefined ? inputFiles[selectedInputFileIndex] : undefined, + () => + selectedInputFileIndex !== undefined + ? inputFiles[selectedInputFileIndex] + : undefined, [inputFiles, selectedInputFileIndex] ); - const [prevInputFilesLength, setPrevInputFilesLength] = useState(inputFiles.length); + const [prevInputFilesLength, setPrevInputFilesLength] = useState( + inputFiles.length + ); - const inputType = useMemo(() => { - if (selectedInputFile) { - if (selectedInputFile.name.endsWith('.json')) { - return INPUT_TYPES.json; - } else if (selectedInputFile.name.endsWith('.xml')) { - return INPUT_TYPES.xml; - } else if (selectedInputFile.name.endsWith('.csv')) { - return INPUT_TYPES.csv; - } - } - }, [selectedInputFile]); + // const inputType = useMemo(() => { + // if (selectedInputFile) { + // if (selectedInputFile.name.endsWith('.json')) { + // return INPUT_TYPES.json; + // } else if (selectedInputFile.name.endsWith('.xml')) { + // return INPUT_TYPES.xml; + // } else if (selectedInputFile.name.endsWith('.csv')) { + // return INPUT_TYPES.csv; + // } + // } + // }, [selectedInputFile]); const changeToInputView = useCallback(() => setView(views.inputs), [setView]); - const updateSelectedInputFile = useCallback((input: string) => { - if (selectedInputFileIndex !== undefined) { - setInputFiles(inputFiles.map((inputFile, index) => { - if (index === selectedInputFileIndex) { - return { ...inputFile, contents: input } - } - return inputFile; - })) - } - }, [selectedInputFileIndex, setInputFiles, inputFiles]); + const updateSelectedInputFile = useCallback( + (input: string) => { + if (selectedInputFileIndex !== undefined) { + setInputFiles( + inputFiles.map((inputFile, index) => { + if (index === selectedInputFileIndex) { + return { ...inputFile, contents: input }; + } + return inputFile; + }) + ); + } + }, + [selectedInputFileIndex, setInputFiles, inputFiles] + ); - const closeSelectedInputFile = useCallback(() => setSelectedInputFileIndex(undefined), []); + const closeSelectedInputFile = useCallback( + () => setSelectedInputFileIndex(undefined), + [] + ); useEffect(() => { if (inputFiles.length !== prevInputFilesLength) { setPrevInputFilesLength(inputFiles.length); if (inputFiles.length > prevInputFilesLength) { - setSelectedInputFileIndex(inputFiles.length-1); + setSelectedInputFileIndex(inputFiles.length - 1); } } }, [inputFiles, prevInputFilesLength]); @@ -63,40 +78,59 @@ function InputPanel({ addNewInput }: InputPanelProps) { return (
- +
- {/* */}
- { !selectedInputFile && ( + {!selectedInputFile && (
- { inputFiles.map((inputFile, index) => { - return
{inputFile.name}
+ {inputFiles.map((inputFile, index) => { + return ( +
+ {inputFile.name} +
+ ); })}
)} - { selectedInputFile && ( + {selectedInputFile && ( <>
- -
{selectedInputFile.name}
+
+ {selectedInputFile.name} +
- )}
- ) + ); } -export default InputPanel; \ No newline at end of file +export default InputPanel; diff --git a/src/components/RMLMappingEditor.tsx b/src/components/RMLMappingEditor.tsx index dee00ff..bc3cd9e 100644 --- a/src/components/RMLMappingEditor.tsx +++ b/src/components/RMLMappingEditor.tsx @@ -35,10 +35,15 @@ const defaultMapping = { ] }; -const defaultInputFiles = [{ +const defaultInputFiles :InputFile[]= [{ name: 'input.json', contents: DEFAULT_INPUT_FILE_BY_TYPE[INPUT_TYPES.json], -}]; +}, +{ + name: 'input.csv', + contents: DEFAULT_INPUT_FILE_BY_TYPE[INPUT_TYPES.csv], +} +]; export function RMLMappingEditor() { const [theme, setTheme] = useState(THEMES.dark); diff --git a/src/contexts/InputContext.tsx b/src/contexts/InputContext.tsx index b5fa7b5..6cffeb0 100644 --- a/src/contexts/InputContext.tsx +++ b/src/contexts/InputContext.tsx @@ -5,13 +5,13 @@ export const INPUT_TYPES = { json: 'json', csv: 'csv', xml: 'xml', -} +}; export const DEFAULT_INPUT_FILE_BY_TYPE = { [INPUT_TYPES.json]: '[\n {\n \n }\n]', - [INPUT_TYPES.csv]: '', - [INPUT_TYPES.xml]: '' -} + [INPUT_TYPES.csv]: 'Name, age\nAlice, 34\nBob, 68', + [INPUT_TYPES.xml]: '', +}; export type InputType = ValueOf; @@ -21,13 +21,13 @@ export interface InputFile { } interface InputContextType { - inputFiles: InputFile[], - setInputFiles: (inputFiles: InputFile[]) => void, + inputFiles: InputFile[]; + setInputFiles: React.Dispatch>; } -const InputContext = createContext({ +const InputContext = createContext({ inputFiles: [], - setInputFiles: (inputFiles: InputFile[]) => {}, + setInputFiles: (inputFiles) => inputFiles, }); -export default InputContext; \ No newline at end of file +export default InputContext; diff --git a/src/css/RMLMappingEditor.module.scss b/src/css/RMLMappingEditor.module.scss index 0f7f936..7a88ffc 100644 --- a/src/css/RMLMappingEditor.module.scss +++ b/src/css/RMLMappingEditor.module.scss @@ -9,16 +9,22 @@ * { box-sizing: border-box; } + + button { + &:hover { + cursor: pointer; + } + } } .rmlEditorDark { composes: rmlEditor; color-scheme: dark; - + --header-icon-color: rgb(227, 230, 234); --secondary-icon-color: rgb(84, 88, 93); --primary-icon-color: rgb(142, 146, 150); - + --primary-text-color: rgb(215, 218, 224); --secondary-text-color: #abb2bf; --tertiary-text-color: rgba(215, 218, 224, 0.5); @@ -97,7 +103,6 @@ justify-content: center; } - .header { composes: centered; padding: 0 10px; @@ -138,13 +143,13 @@ transition: background-color 0.2s; } -.draggableViewSection .dragHandle, +.draggableViewSection .dragHandle, .draggableViewSection::after { position: absolute; background-color: transparent; } -.draggableViewSection.dragHandleLongHover::after, +.draggableViewSection.dragHandleLongHover::after, .draggableViewSection.dragging::after { background-color: var(--context-menu-border-color); } @@ -163,7 +168,7 @@ .draggableViewContainer:not(.vertical) { .draggableViewSection > .dragHandle, - .draggableViewSection.dragHandleLongHover::after, + .draggableViewSection.dragHandleLongHover::after, .draggableViewSection.dragging::after { height: 100%; right: -3px; @@ -175,7 +180,7 @@ .panel { background-color: var(--side-panel-background-color); - height:100%; + height: 100%; } .stretch { @@ -229,7 +234,7 @@ display: flex; align-items: center; width: 100%; - height:32px; + height: 32px; padding: 0 6px; border-bottom: 1px solid var(--header-border-color); } @@ -275,7 +280,7 @@ align-items: center; justify-content: flex-end; width: 100%; - height:32px; + height: 32px; padding: 0 10px; font-size: 13px; color: var(--warning-text-color); @@ -300,7 +305,7 @@ padding: 8px; } -.inputFile { +.inputFile { cursor: pointer; padding: 7px 10px; border-radius: 5px; @@ -332,7 +337,7 @@ top: 0; bottom: 0; z-index: 101; - background-color: rgba(0,0,0,0.5); + background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; @@ -349,10 +354,10 @@ flex-direction: column; background-color: var(--main-background-color); border: 1px solid var(--dropdown-border-color); - } -.modalHeader, .modalFooter { +.modalHeader, +.modalFooter { padding: 20px 20px 15px 20px; display: flex; align-items: center; @@ -416,6 +421,61 @@ &:hover { background-color: var(--button-selected-color); - color: var(--button-selected-text-color) + color: var(--button-selected-text-color); + } +} + +.addTableItemContainer { + display: flex; + justify-content: end; + padding: 8px; +} + +.addTableItem { + color: var(--primary-text-color); + border: none; + border-radius: 5px; + background: none; + + &:hover { + background-color: var(--button-hover-color); + color: var(--button-selected-text-color); } } +.actionsCell{ + background: var(--header-background-color); +} + +.csvEditorTable { + color: var(--primary-text-color); + border-collapse: collapse; + table-layout: fixed; + + td:not(.actionsCell) { + border: 1px solid var(--secondary-text-color); + } + + tr:nth-child(even) { + background: var(--button-hover-color); + } +} + +.tableheader { + font-weight: 600; +} + +.cell { + padding: 4px 8px; +} + +.tableActionsContainer { + width: fit-content; + display: flex; + margin: 0 auto; +} + +.tableItemInput { + border: none; + outline: none; + width: fit-content; +}