Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
237 changes: 225 additions & 12 deletions src/frontend/components/_features/[workspace]/data-type/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { ComponentPropsWithoutRef, useEffect, useState } from 'react'
import { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react'

import type { PLCDataType } from '../../../../../middleware/shared/ports/types'
import { CodeIcon } from '../../../../assets/icons/interface/CodeIcon'
import { TableIcon } from '../../../../assets/icons/interface/TableIcon'
import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot'
import { useOpenPLCStore } from '../../../../store'
import { extractSearchQuery } from '../../../../store/slices/search/utils'
import { cn } from '../../../../utils/cn'
import { isDataTypeFilesEnabled } from '../../../../utils/feature-flags'
import { serializeDataTypeToText } from '../../../../utils/PLC/data-type-serializer'
import { parseDataTypeFromText } from '../../../../utils/PLC/data-type-text-parser'
import { InputWithRef } from '../../../_atoms/input'
import { ArrayDataType } from '../../../_molecules/data-types/array'
import { EnumeratorDataType } from '../../../_molecules/data-types/enumerated'
import { StructureDataType } from '../../../_molecules/data-types/structure'
import { VariablesCodeEditor } from '../../../_organisms/variables-code-editor'
import { toast } from '../../[app]/toast/use-toast'

type DatatypeEditorProps = ComponentPropsWithoutRef<'div'> & {
Expand All @@ -15,23 +23,183 @@

const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => {
const {
editor,
editors,
project: {
data: { dataTypes },
},
unparsedDataTypeFiles,
workspace: {
systemConfigs: { shouldUseDarkMode },
},
datatypeActions: { rename },
editorActions: { updateModelStructureForName },
projectActions: { createDatatype, removeUnparsedDataTypeFile, updateDatatype },
sharedWorkspaceActions: { handleFileAndWorkspaceSavedState },
searchQuery,
} = useOpenPLCStore()
const { captureAndPush } = usePouSnapshot()

// Every open data type is mounted at once, so the view state comes from
// this type's own model — never from the active `editor`.
const model = editor.meta.name === dataTypeName ? editor : editors.find((e) => e.meta.name === dataTypeName)
const modelStructure = model?.type === 'plc-datatype' ? model.structure : undefined
const codeViewEnabled = isDataTypeFilesEnabled()
const display = codeViewEnabled && modelStructure?.display === 'code' ? 'code' : 'table'
const modelCode = modelStructure?.display === 'code' ? modelStructure.code : undefined

// An unparseable file has no entry in `dataTypes` — raw text is all there is.
const rawFile = unparsedDataTypeFiles.find(
(file) => file.relativePath.split('/').pop()?.replace(/\.dt$/i, '') === dataTypeName,
)

const [editorContent, setEditorContent] = useState<PLCDataType>()
const [isEditing, setIsEditing] = useState(false)
const [editorCode, setEditorCode] = useState(() => {
if (typeof modelCode === 'string') return modelCode
const dataType = dataTypes.find((candidate) => candidate.name === dataTypeName)
return dataType ? serializeDataTypeToText(dataType) : (rawFile?.content ?? '')
})
const [parseError, setParseError] = useState<string | null>(null)

const containerRef = useRef<HTMLDivElement>(null)
const latestCodeRef = useRef(editorCode)
const latestDisplayRef = useRef(display)
const lastParsedCodeRef = useRef(editorCode)
const lastRejectedCodeRef = useRef<string | null>(null)
const lastMirroredCodeRef = useRef(editorCode)
const isParsingRef = useRef(false)
const commitCodeRef = useRef<() => boolean>(() => false)

useEffect(() => {
const dataTypeIndex = dataTypes.findIndex((dataType) => dataType.name === dataTypeName)
if (dataTypeIndex !== -1) {
const dataType = dataTypes[dataTypeIndex]
setEditorContent(dataType)
}
const dataType = dataTypes.find((candidate) => candidate.name === dataTypeName)
if (dataType) setEditorContent(dataType)
}, [dataTypes, dataTypeName])

// In table mode the form is the committed state: it seeds both the buffer
// and the watermark, so the toggle is instant and can't commit a no-op.
useEffect(() => {
if (display === 'code') return
const text = editorContent ? serializeDataTypeToText(editorContent) : (rawFile?.content ?? '')
setEditorCode(text)
lastParsedCodeRef.current = text
}, [editorContent, display, rawFile?.content])

// Adopt store-written buffers (rename, undo), never the echo of our own
// mirror below — that would race the keystroke that produced it.
useEffect(() => {
if (display !== 'code' || typeof modelCode !== 'string') return
if (modelCode === lastMirroredCodeRef.current) return
setEditorCode(modelCode)
}, [display, modelCode])

useEffect(() => {
if (display !== 'code') return
lastMirroredCodeRef.current = editorCode
updateModelStructureForName(dataTypeName, { display: 'code', code: editorCode })
}, [editorCode, display, dataTypeName, updateModelStructureForName])

useEffect(() => {
latestCodeRef.current = editorCode
latestDisplayRef.current = display
}, [editorCode, display])

useEffect(() => {
return () => {
if (latestDisplayRef.current === 'code') {
updateModelStructureForName(dataTypeName, { display: 'code', code: latestCodeRef.current })
}
}
}, [dataTypeName, updateModelStructureForName])

// No type yet means a broken file — surface why while editing, not on commit.
useEffect(() => {
if (display !== 'code' || editorContent) return
setParseError(parseDataTypeFromText(editorCode, dataTypeName).error ?? null)
}, [display, editorContent, editorCode, dataTypeName])

const commitCode = (): boolean => {
const { dataType, error } = parseDataTypeFromText(editorCode, dataTypeName)
if (!dataType) {
const message = error ?? 'Unexpected syntax error.'
setParseError(message)
toast({ title: 'Syntax error', description: message, variant: 'fail' })
return false
}

captureAndPush(dataTypeName)

if (editorContent) {
updateDatatype(dataTypeName, dataType)
} else {
const result = createDatatype({ data: dataType })
if (!result.ok) {
const message = result.message ?? 'Could not create the data type.'
setParseError(message)
toast({ title: 'Syntax error', description: message, variant: 'fail' })
return false
}
if (rawFile) removeUnparsedDataTypeFile(rawFile.relativePath)
}

handleFileAndWorkspaceSavedState(dataTypeName)
setParseError(null)
return true
}

useEffect(() => {
commitCodeRef.current = commitCode
})

useEffect(() => {
if (display !== 'code') return

// Clicking away raises mousedown then focusout, and the commit is
// synchronous, so `isParsingRef` is clear by the second one. Both
// watermarks make the pair one attempt whatever its outcome.
const tryCommit = () => {
if (isParsingRef.current) return
if (editorCode === lastParsedCodeRef.current) return
if (editorCode === lastRejectedCodeRef.current) return
isParsingRef.current = true
if (commitCodeRef.current()) {
lastParsedCodeRef.current = editorCode
lastRejectedCodeRef.current = null
} else {
lastRejectedCodeRef.current = editorCode
}
isParsingRef.current = false
}

const onDocMouseDown = (e: MouseEvent) => {
if (!containerRef.current) return
if (containerRef.current.contains(e.target as Node)) return
tryCommit()
}

// Covers focus moves with no mousedown: Tab, shortcuts.
const onFocusOut = (e: FocusEvent) => {
if (!containerRef.current) return
const newTarget = e.relatedTarget as Node | null
if (newTarget && containerRef.current.contains(newTarget)) return
tryCommit()
}

const container = containerRef.current
document.addEventListener('mousedown', onDocMouseDown, true)
container?.addEventListener('focusout', onFocusOut)
return () => {
document.removeEventListener('mousedown', onDocMouseDown, true)
container?.removeEventListener('focusout', onFocusOut)
}
}, [display, editorCode])
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleVisualizationTypeChange = (value: 'code' | 'table') => {
if (display === value) return
if (display === 'code' && !commitCode()) return
updateModelStructureForName(dataTypeName, { display: value, code: value === 'code' ? editorCode : undefined })
}

const handleStartEditing = () => {
setIsEditing(true)
}
Expand Down Expand Up @@ -64,6 +232,7 @@

return (
<div
ref={containerRef}
aria-label='Data type editor container'
className=' flex h-full w-full flex-col gap-4 overflow-hidden'
{...rest}
Expand All @@ -85,7 +254,7 @@
>
{isEditing ? (
<InputWithRef
value={editorContent?.name}
value={editorContent?.name ?? dataTypeName}
onChange={handleChange}
onBlur={handleBlur}
id='data-type-name'
Expand All @@ -97,16 +266,60 @@
aria-label='Data type name'
className='h-full w-full bg-transparent p-2 text-start font-caption text-xs text-neutral-850 outline-none dark:text-neutral-100'
onClick={handleStartEditing}
dangerouslySetInnerHTML={{ __html: extractSearchQuery(editorContent?.name || '', searchQuery) }}
dangerouslySetInnerHTML={{
__html: extractSearchQuery(editorContent?.name ?? dataTypeName, searchQuery),
}}
/>
)}
</div>
</div>
{codeViewEnabled && (
<div
aria-label='Data type visualization switch container'
className='ml-auto flex h-fit w-fit items-center justify-center rounded-md'
>
<TableIcon
aria-label='Data type table visualization'
onClick={() => handleVisualizationTypeChange('table')}
size='md'
currentVisible={display === 'table'}
className={cn(
display === 'table' ? 'fill-brand' : 'fill-neutral-100 dark:fill-neutral-900',
'rounded-l-md transition-colors ease-in-out hover:cursor-pointer',
)}
/>
<CodeIcon
aria-label='Data type code visualization'
onClick={() => handleVisualizationTypeChange('code')}
size='md'
currentVisible={display === 'code'}
className={cn(
display === 'code' ? 'fill-brand' : 'fill-neutral-100 dark:fill-neutral-900',
'rounded-r-md transition-colors ease-in-out hover:cursor-pointer',
)}
/>
</div>
)}
Comment thread
JoaoGSP marked this conversation as resolved.
</div>
<div aria-label='Data type content container' className='h-full w-full overflow-hidden'>
{editorContent?.derivation === 'array' && <ArrayDataType data={editorContent} />}
{editorContent?.derivation === 'enumerated' && <EnumeratorDataType data={editorContent} />}
{editorContent?.derivation === 'structure' && <StructureDataType />}
<div aria-label='Data type content container' className='flex h-full w-full flex-col overflow-hidden'>
{display === 'table' ? (
<>
{editorContent?.derivation === 'array' && <ArrayDataType data={editorContent} />}
{editorContent?.derivation === 'enumerated' && <EnumeratorDataType data={editorContent} />}
{editorContent?.derivation === 'structure' && <StructureDataType />}
</>
) : (
<>
<div aria-label='Data type code container' className='h-full w-full overflow-hidden'>
<VariablesCodeEditor
code={editorCode}
onCodeChange={setEditorCode}
shouldUseDarkMode={shouldUseDarkMode}
/>
</div>
{parseError && <p className='mt-2 shrink-0 text-xs text-red-500'>Error: {parseError}</p>}
</>
)}
</div>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { useEffect, useState } from 'react'

import type { PLCStructureVariable } from '../../../../../middleware/shared/ports/types'
Expand Down Expand Up @@ -28,7 +28,8 @@

const [tableData, setTableData] = useState<PLCStructureVariable[]>([])

const [editorStructure, setEditorStructure] = useState<StructureTableType>({
const [editorStructure, setEditorStructure] = useState<Extract<StructureTableType, { display: 'table' }>>({
display: 'table',
selectedRow: ROWS_NOT_SELECTED.toString(),
description: '',
})
Expand All @@ -47,9 +48,14 @@

useEffect(() => {
const foundDataType = dataTypes.find((dataType) => dataType?.derivation === 'structure')
if (editor.type === 'plc-datatype' && foundDataType && 'variable' in foundDataType) {
if (
editor.type === 'plc-datatype' &&
editor.structure.display === 'table' &&
foundDataType &&
'variable' in foundDataType
) {
const { description, selectedRow } = editor.structure
setEditorStructure({ description: description, selectedRow: selectedRow })
setEditorStructure({ display: 'table', description: description, selectedRow: selectedRow })
}
}, [editor])
Comment thread
JoaoGSP marked this conversation as resolved.

Expand Down
Loading
Loading