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
24 changes: 18 additions & 6 deletions src/frontend/components/_features/[workspace]/data-type/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react'

import type { PLCDataType } from '../../../../../middleware/shared/ports/types'
Expand All @@ -8,6 +8,7 @@
import { extractSearchQuery } from '../../../../store/slices/search/utils'
import { cn } from '../../../../utils/cn'
import { isDataTypeFilesEnabled } from '../../../../utils/feature-flags'
import { getErrorMessage } from '../../../../utils/get-error-message'
import { serializeDataTypeToText } from '../../../../utils/PLC/data-type-serializer'
import { parseDataTypeFromText } from '../../../../utils/PLC/data-type-text-parser'
import { InputWithRef } from '../../../_atoms/input'
Expand Down Expand Up @@ -221,12 +222,23 @@
if (dataTypeName !== value) {
// `datatypeActions.rename` validates the new name and rekeys the
// editor model, tab, and file entry, then flags the file dirty.
const result = rename(dataTypeName, value)
if (!result.ok) {
setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent))
toast({ title: 'Rename failed', description: result.message, variant: 'fail' })
}
setIsEditing(false)
// Async: a referenced type awaits the impact modal first.
void rename(dataTypeName, value)
.then((result) => {
if (!result.ok) {
setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent))
// A declined impact modal is a user choice, not a failure.
if (!result.cancelled) {
toast({ title: 'Rename failed', description: result.message, variant: 'fail' })
}
}
setIsEditing(false)
})
.catch((error: unknown) => {
setEditorContent((prevContent) => (prevContent ? { ...prevContent, name: dataTypeName } : prevContent))
toast({ title: 'Rename failed', description: getErrorMessage(error), variant: 'fail' })
setIsEditing(false)
})
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/frontend/components/_molecules/project-tree/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import * as Popover from '@radix-ui/react-popover'
import { ComponentPropsWithoutRef, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'

Expand Down Expand Up @@ -594,8 +594,12 @@
}

if (isDatatype) {
const res = renameDatatype(label, newLabel)
if (!res.ok) setNewLabel(label || '')
// Async: a referenced type awaits the impact modal before renaming.
void renameDatatype(label, newLabel)
.then((res) => {
if (!res.ok) setNewLabel(label || '')
})
.catch(() => setNewLabel(label || ''))
return
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useOpenPLCStore } from '../../../store'
import { RenameImpactModal } from '.'

/**
* Store-driven host for the data type rename flow: `datatypeActions.rename`
* parks the awaited confirmation in `pendingDatatypeRename`, this renders the
* impact modal for it, and confirm/cancel resolve the pending promise via
* `respondToPendingRename`.
*/
export const DataTypeRenameImpactModal = () => {
const pending = useOpenPLCStore((s) => s.pendingDatatypeRename)
const respondToPendingRename = useOpenPLCStore((s) => s.datatypeActions.respondToPendingRename)

if (!pending) return null

return (
<RenameImpactModal
open
title='Data Type Rename: Impact Analysis'
oldName={pending.oldName}
newName={pending.newName}
impact={pending.impact}
affectedListLabel='Affected locations:'
byKindLabel='By reference kind:'
cancelLabel='No, cancel rename'
cancelDescription='The data type keeps its current name and nothing is changed'
onConfirm={() => respondToPendingRename(true)}
onCancel={() => respondToPendingRename(false)}
/>
)
}
37 changes: 25 additions & 12 deletions src/frontend/components/_molecules/rename-impact-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { ReferenceImpactAnalysis } from '../../../utils/variable-references/types'
import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '../modal'

Expand All @@ -6,7 +6,15 @@
oldName?: string
newName?: string
changes?: Array<{ oldName: string; newName?: string; oldType?: string; newType?: string }>
impact: ReferenceImpactAnalysis
// The modal only renders the aggregate maps, so any location shape works.
impact: ReferenceImpactAnalysis<unknown>
// Copy overrides — defaults keep the original variable-rename wording.
title?: string
affectedListLabel?: string
byKindLabel?: string
confirmLabel?: string
cancelLabel?: string
cancelDescription?: string
onConfirm: () => void
onCancel: () => void
}
Expand All @@ -17,6 +25,12 @@
newName,
changes,
impact,
title = 'Variable Changes: Impact Analysis',
affectedListLabel = 'Affected POUs:',
byKindLabel = 'By Editor Type:',
confirmLabel = 'Yes, rename references',
cancelLabel = 'No, keep references unchanged',
cancelDescription = 'References will remain with the old name and will no longer match the renamed variable, causing them to become unresolved references',
onConfirm,
onCancel,
}: RenameImpactModalProps) => {
Expand All @@ -32,9 +46,7 @@
onClose={onCancel}
>
<ModalHeader>
<ModalTitle className='text-sm font-medium text-neutral-950 dark:text-white'>
Variable Changes: Impact Analysis
</ModalTitle>
<ModalTitle className='text-sm font-medium text-neutral-950 dark:text-white'>{title}</ModalTitle>
</ModalHeader>

<div className='flex flex-col gap-3 overflow-y-auto'>
Expand Down Expand Up @@ -91,7 +103,9 @@

{impact.byPou.size > 0 && (
<div className='mb-3'>
<div className='mb-1 text-xs font-medium text-neutral-700 dark:text-neutral-300'>Affected POUs:</div>
<div className='mb-1 text-xs font-medium text-neutral-700 dark:text-neutral-300'>
{affectedListLabel}
</div>
<ul className='list-inside list-disc space-y-1 text-xs text-neutral-600 dark:text-neutral-400'>
{Array.from(impact.byPou.entries()).map(([pouName, count]) => (
<li key={pouName}>
Expand All @@ -104,7 +118,7 @@

{impact.byEditorType.size > 0 && (
<div>
<div className='mb-1 text-xs font-medium text-neutral-700 dark:text-neutral-300'>By Editor Type:</div>
<div className='mb-1 text-xs font-medium text-neutral-700 dark:text-neutral-300'>{byKindLabel}</div>
<ul className='list-inside list-disc space-y-1 text-xs text-neutral-600 dark:text-neutral-400'>
{Array.from(impact.byEditorType.entries()).map(([editorType, count]) => (
<li key={editorType}>
Expand All @@ -120,12 +134,11 @@
<p className='font-medium'>What would you like to do?</p>
<ul className='mt-2 list-inside list-disc space-y-1'>
<li>
<span className='font-semibold'>Yes, rename references:</span> All references will be updated to use the
new name
<span className='font-semibold'>{confirmLabel}:</span> All references will be updated to use the new
name
</li>
<li>
<span className='font-semibold'>No, keep references unchanged:</span> References will remain with the
old name and will no longer match the renamed variable, causing them to become unresolved references
<span className='font-semibold'>{cancelLabel}:</span> {cancelDescription}
</li>
</ul>
</div>
Expand All @@ -136,10 +149,10 @@
onClick={onCancel}
className='h-8 w-full rounded bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-1000 dark:bg-neutral-850 dark:text-neutral-100'
>
No, keep references unchanged
{cancelLabel}
</button>
<button onClick={onConfirm} className='h-8 w-full rounded bg-brand px-3 py-1 text-xs text-white'>
Yes, rename references
{confirmLabel}
</button>
</ModalFooter>
</ModalContent>
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/components/_templates/app-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { ComponentPropsWithoutRef, ReactNode, useCallback, useEffect, useState } from 'react'

import { useCapabilities, useProject, useSystem, useTheme } from '../../../middleware/shared/providers'
Expand All @@ -8,6 +8,7 @@
import Toaster from '../_features/[app]/toast/toaster'
import { ProjectModal } from '../_features/[start]/new-project/project-modal'
import { AIConsentModal } from '../_features/[workspace]/editor/monaco/ai-consent-modal'
import { DataTypeRenameImpactModal } from '../_molecules/rename-impact-modal/data-type-rename-impact-modal'
import AboutModal from '../_organisms/about-modal'
import { RuntimeCreateUserModal, RuntimeDiscoverDevicesModal, RuntimeLoginModal } from '../_organisms/modals'
import { ConfirmDeleteProjectModal } from '../_organisms/modals/confirm-delete-project-modal'
Expand Down Expand Up @@ -128,6 +129,7 @@
{modals?.['confirm-delete-project']?.open === true && (
<ConfirmDeleteProjectModal isOpen={modals['confirm-delete-project'].open} />
)}
<DataTypeRenameImpactModal />
{modals?.['confirm-plcopen-import']?.open === true && (
<ConfirmPlcopenImportModal isOpen={modals['confirm-plcopen-import'].open} />
)}
Expand Down
125 changes: 125 additions & 0 deletions src/frontend/store/__tests__/project-slice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,131 @@ describe('createProjectSlice', () => {
})
})

describe('propagateDatatypeRename', () => {
const directRef = (name: string, typeName: string): PLCVariable => ({
name,
class: 'local',
type: { definition: 'user-data-type', value: typeName },
location: '',
documentation: '',
})
const arrayRef = (name: string, typeName: string): PLCVariable => ({
name,
class: 'local',
type: {
definition: 'array',
value: `ARRAY [0..4] OF ${typeName}`,
data: {
baseType: { definition: 'user-data-type', value: typeName },
dimensions: [{ dimension: '0..4' }],
},
},
location: '',
documentation: '',
})

beforeEach(() => {
store.getState().projectActions.createPou({
type: 'program',
data: {
language: 'st',
name: 'Main',
variables: [directRef('motor', 'MotorDef'), arrayRef('motors', 'motordef'), makeVariable('plain')],
body: makeBody(),
documentation: '',
},
})
store.getState().projectActions.setGlobalVariables({
variables: [{ ...directRef('gMotor', 'MotorDef'), class: 'global' }, makeVariable('gPlain', 'global')],
})
store.getState().projectActions.createDatatype({
data: {
name: 'MotorDef',
derivation: 'structure',
variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }],
},
})
store.getState().projectActions.createDatatype({
data: {
name: 'Chassis',
derivation: 'structure',
variable: [
{ name: 'front', type: { definition: 'user-data-type', value: 'MotorDef' } },
{ name: 'id', type: { definition: 'base-type', value: 'INT' } },
],
},
})
store.getState().projectActions.createDatatype({
data: {
name: 'MotorBank',
derivation: 'array',
baseType: { definition: 'user-data-type', value: 'MotorDef' },
initialValue: '',
dimensions: [{ dimension: '1..8' }],
},
})
})

it('rewrites direct and array POU variable references (case-insensitive)', () => {
store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef')

const variables = store.getState().project.data.pous[0].interface?.variables ?? []
expect(variables[0].type).toEqual({ definition: 'user-data-type', value: 'DriveDef' })
expect(variables[1].type).toEqual({
definition: 'array',
value: 'ARRAY [0..4] OF DriveDef',
data: {
baseType: { definition: 'user-data-type', value: 'DriveDef' },
dimensions: [{ dimension: '0..4' }],
},
})
expect(variables[2].type).toEqual({ definition: 'base-type', value: 'INT' })
})

it('rewrites global variable references', () => {
store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef')

const globals = store.getState().project.data.configurations.resource.globalVariables
expect(globals[0].type).toEqual({ definition: 'user-data-type', value: 'DriveDef' })
expect(globals[1].type).toEqual({ definition: 'base-type', value: 'INT' })
})

it('rewrites other data types and leaves the renamed type entry itself alone', () => {
store.getState().projectActions.propagateDatatypeRename('MotorDef', 'DriveDef')

const dataTypes = store.getState().project.data.dataTypes
// The type's own entry is updateDatatypeName's job.
expect(dataTypes[0].name).toBe('MotorDef')
expect(dataTypes[1]).toEqual({
name: 'Chassis',
derivation: 'structure',
variable: [
{ name: 'front', type: { definition: 'user-data-type', value: 'DriveDef' } },
{ name: 'id', type: { definition: 'base-type', value: 'INT' } },
],
})
expect(dataTypes[2]).toEqual({
name: 'MotorBank',
derivation: 'array',
baseType: { definition: 'user-data-type', value: 'DriveDef' },
initialValue: '',
dimensions: [{ dimension: '1..8' }],
})
})

it('is a no-op when nothing references the type', () => {
const before = store.getState().project
store.getState().projectActions.propagateDatatypeRename('Ghost', 'Phantom')
const after = store.getState().project

expect(after.data.pous).toEqual(before.data.pous)
expect(after.data.configurations.resource.globalVariables).toEqual(
before.data.configurations.resource.globalVariables,
)
expect(after.data.dataTypes).toEqual(before.data.dataTypes)
})
})

describe('setUnparsedDataTypeFiles', () => {
it('replaces the stashed raw .dt files', () => {
const raw = [{ relativePath: 'datatypes/Broken.dt', content: 'TYPE garbage' }]
Expand Down
Loading
Loading