diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx index 44190d68a..ae5a87ab9 100644 --- a/src/frontend/components/_features/[workspace]/data-type/index.tsx +++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx @@ -8,6 +8,7 @@ import { useOpenPLCStore } from '../../../../store' 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' @@ -221,12 +222,23 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => { 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) + }) } } diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index e0d859fd0..bb152e3f1 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -594,8 +594,12 @@ const ProjectTreeLeaf = ({ } 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 } diff --git a/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx b/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx new file mode 100644 index 000000000..bcfd5e02f --- /dev/null +++ b/src/frontend/components/_molecules/rename-impact-modal/data-type-rename-impact-modal.tsx @@ -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 ( + respondToPendingRename(true)} + onCancel={() => respondToPendingRename(false)} + /> + ) +} diff --git a/src/frontend/components/_molecules/rename-impact-modal/index.tsx b/src/frontend/components/_molecules/rename-impact-modal/index.tsx index a089821ef..1871285f7 100644 --- a/src/frontend/components/_molecules/rename-impact-modal/index.tsx +++ b/src/frontend/components/_molecules/rename-impact-modal/index.tsx @@ -6,7 +6,15 @@ type RenameImpactModalProps = { 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 + // 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 } @@ -17,6 +25,12 @@ export const RenameImpactModal = ({ 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) => { @@ -32,9 +46,7 @@ export const RenameImpactModal = ({ onClose={onCancel} > - - Variable Changes: Impact Analysis - + {title}
@@ -91,7 +103,9 @@ export const RenameImpactModal = ({ {impact.byPou.size > 0 && (
-
Affected POUs:
+
+ {affectedListLabel} +
    {Array.from(impact.byPou.entries()).map(([pouName, count]) => (
  • @@ -104,7 +118,7 @@ export const RenameImpactModal = ({ {impact.byEditorType.size > 0 && (
    -
    By Editor Type:
    +
    {byKindLabel}
      {Array.from(impact.byEditorType.entries()).map(([editorType, count]) => (
    • @@ -120,12 +134,11 @@ export const RenameImpactModal = ({

      What would you like to do?

      • - Yes, rename references: All references will be updated to use the - new name + {confirmLabel}: All references will be updated to use the new + name
      • - No, keep references unchanged: References will remain with the - old name and will no longer match the renamed variable, causing them to become unresolved references + {cancelLabel}: {cancelDescription}
    @@ -136,10 +149,10 @@ export const RenameImpactModal = ({ 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} diff --git a/src/frontend/components/_templates/app-layout.tsx b/src/frontend/components/_templates/app-layout.tsx index ad740bc96..375fc54ad 100644 --- a/src/frontend/components/_templates/app-layout.tsx +++ b/src/frontend/components/_templates/app-layout.tsx @@ -8,6 +8,7 @@ import { ResolutionWarning } from '../_atoms/resolution-warning-message' 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' @@ -128,6 +129,7 @@ const AppLayout = ({ children, ...rest }: AppLayoutProps): ReactNode => { {modals?.['confirm-delete-project']?.open === true && ( )} + {modals?.['confirm-plcopen-import']?.open === true && ( )} diff --git a/src/frontend/store/__tests__/project-slice.test.ts b/src/frontend/store/__tests__/project-slice.test.ts index a034bd51a..008a520be 100644 --- a/src/frontend/store/__tests__/project-slice.test.ts +++ b/src/frontend/store/__tests__/project-slice.test.ts @@ -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' }] diff --git a/src/frontend/store/__tests__/shared-slice.test.ts b/src/frontend/store/__tests__/shared-slice.test.ts index 066e640e9..3a2779100 100644 --- a/src/frontend/store/__tests__/shared-slice.test.ts +++ b/src/frontend/store/__tests__/shared-slice.test.ts @@ -561,8 +561,8 @@ describe('createSharedSlice', () => { store.getState().datatypeActions.create({ name: 'OldDT', derivation: 'structure' }) }) - it('renames data type across all slices', () => { - const result = store.getState().datatypeActions.rename('OldDT', 'NewDT') + it('renames data type across all slices', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'NewDT') expect(result).toEqual({ ok: true }) const state = store.getState() @@ -572,18 +572,18 @@ describe('createSharedSlice', () => { expect(state.tabs[0].name).toBe('NewDT') }) - it('queues the old datatypes/.dt path for deletion', () => { - store.getState().datatypeActions.rename('OldDT', 'NewDT') + it('queues the old datatypes/.dt path for deletion', async () => { + await store.getState().datatypeActions.rename('OldDT', 'NewDT') expect(store.getState().pendingDeletions).toContain('datatypes/OldDT.dt') }) - it('folds pending code-view edits in and rewrites the buffer under the new name', () => { + it('folds pending code-view edits in and rewrites the buffer under the new name', async () => { store.getState().editorActions.updateModelStructureForName('OldDT', { display: 'code', code: 'TYPE\nOldDT : STRUCT\nspeed : INT;\nEND_STRUCT;\nEND_TYPE\n', }) - expect(store.getState().datatypeActions.rename('OldDT', 'NewDT').ok).toBe(true) + expect((await store.getState().datatypeActions.rename('OldDT', 'NewDT')).ok).toBe(true) const renamed = store.getState().project.data.dataTypes[0] expect(renamed.name).toBe('NewDT') @@ -595,69 +595,357 @@ describe('createSharedSlice', () => { ) }) - it('refuses the rename while the code view holds invalid text', () => { + it('refuses the rename while the code view holds invalid text', async () => { store .getState() .editorActions.updateModelStructureForName('OldDT', { display: 'code', code: 'TYPE\ngarbage\nEND_TYPE\n' }) - const result = store.getState().datatypeActions.rename('OldDT', 'NewDT') + const result = await store.getState().datatypeActions.rename('OldDT', 'NewDT') expect(result.ok).toBe(false) expect(store.getState().project.data.dataTypes[0].name).toBe('OldDT') expect(store.getState().pendingDeletions).not.toContain('datatypes/OldDT.dt') }) - it('rejects a name owned by an unreadable .dt file (case-insensitive)', () => { + it('rejects a name owned by an unreadable .dt file (case-insensitive)', async () => { store .getState() .projectActions.setUnparsedDataTypeFiles([{ relativePath: 'datatypes/Ghost.dt', content: 'TYPE garbage' }]) - const result = store.getState().datatypeActions.rename('OldDT', 'ghost') + const result = await store.getState().datatypeActions.rename('OldDT', 'ghost') expect(result.ok).toBe(false) expect(result.message).toMatch(/could not be read/) }) - it('rejects a rename that collides with another type only by case', () => { + it('rejects a rename that collides with another type only by case', async () => { store.getState().datatypeActions.create({ name: 'Motor', derivation: 'array' }) - const result = store.getState().datatypeActions.rename('OldDT', 'motor') + const result = await store.getState().datatypeActions.rename('OldDT', 'motor') expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') expect(store.getState().project.data.dataTypes.map((d) => d.name)).toEqual(['OldDT', 'Motor']) }) - it('rejects a case-only rename of the type itself', () => { + it('rejects a case-only rename of the type itself', async () => { // Writing olddt.dt then deleting OldDT.dt is the same file // where the filesystem folds case — the type would vanish. - const result = store.getState().datatypeActions.rename('OldDT', 'olddt') + const result = await store.getState().datatypeActions.rename('OldDT', 'olddt') expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') }) - it('allows a no-op rename to the identical name', () => { - const result = store.getState().datatypeActions.rename('OldDT', 'OldDT') + it('allows a no-op rename to the identical name', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'OldDT') expect(result.ok).toBe(true) }) - it('returns error when new name already exists', () => { + it('returns error when new name already exists', async () => { store.getState().datatypeActions.create({ name: 'Existing', derivation: 'array' }) - const result = store.getState().datatypeActions.rename('OldDT', 'Existing') + const result = await store.getState().datatypeActions.rename('OldDT', 'Existing') expect(result.ok).toBe(false) expect(result.message).toBe('Data type name already exists') }) - it('returns error when data type not found', () => { - const result = store.getState().datatypeActions.rename('NonExistent', 'NewName') + it('returns error when data type not found', async () => { + const result = await store.getState().datatypeActions.rename('NonExistent', 'NewName') expect(result.ok).toBe(false) expect(result.message).toBe('Data type not found') }) - it('updates editor name when renaming the current editor', () => { + it('updates editor name when renaming the current editor', async () => { // OldDT is the current editor expect(store.getState().editor.meta.name).toBe('OldDT') - const result = store.getState().datatypeActions.rename('OldDT', 'RenamedDT') + const result = await store.getState().datatypeActions.rename('OldDT', 'RenamedDT') expect(result.ok).toBe(true) expect(store.getState().editor.meta.name).toBe('RenamedDT') }) }) + // ----------------------------------------------------------------------- + // rename with references (impact modal) + // ----------------------------------------------------------------------- + describe('rename with references (impact modal)', () => { + 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().datatypeActions.create({ name: 'OldDT', derivation: 'structure' }) + store.getState().datatypeActions.create({ name: 'Chassis', derivation: 'structure' }) + store.getState().projectActions.updateDatatype('Chassis', { + name: 'Chassis', + derivation: 'structure', + variable: [{ name: 'front', type: { definition: 'user-data-type', value: 'OldDT' } }], + }) + store.getState().datatypeActions.create({ name: 'Bank', derivation: 'array' }) + store.getState().projectActions.updateDatatype('Bank', { + name: 'Bank', + derivation: 'array', + baseType: { definition: 'user-data-type', value: 'OldDT' }, + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + store.getState().pouActions.create({ type: 'program', name: 'Main', language: 'st' }) + store.getState().projectActions.setPouVariables({ + pouName: 'Main', + variables: [directRef('motor', 'OldDT'), arrayRef('motors', 'olddt')], + }) + store.getState().projectActions.setGlobalVariables({ + variables: [{ ...directRef('gMotor', 'OldDT'), class: 'global' }], + }) + store.getState().fileActions.addFile({ name: 'Resource', type: 'resource', filePath: 'Resource' }) + store.getState().fileActions.setAllToSaved() + store.getState().workspaceActions.setEditingState('saved') + }) + + // The variables view of a POU model — active editor or stored model, + // same preference order the propagation sync uses. + const getVariableView = (name: string) => { + const state = store.getState() + const model = state.editor.meta.name === name ? state.editor : state.editorActions.getEditorFromEditors(name) + if (!model || (model.type !== 'plc-textual' && model.type !== 'plc-graphical')) return undefined + return model.variable + } + const getCodeBuffer = (name: string) => { + const view = getVariableView(name) + return view?.display === 'code' ? view.code : undefined + } + + it('parks a pending rename and leaves the store untouched until answered', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + + const pending = store.getState().pendingDatatypeRename + expect(pending?.oldName).toBe('OldDT') + expect(pending?.newName).toBe('NewDT') + expect(pending?.impact.totalReferences).toBe(5) + expect(Array.from(pending?.impact.byPou.entries() ?? [])).toEqual([ + ['Main', 2], + ['Global Variables', 1], + ['Chassis', 1], + ['Bank', 1], + ]) + // Nothing renamed while the modal is open. + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toEqual(['OldDT', 'Chassis', 'Bank']) + + store.getState().datatypeActions.respondToPendingRename(true) + await promise + }) + + it('confirm propagates every reference shape, then renames', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + const result = await promise + + expect(result).toEqual({ ok: true }) + expect(store.getState().pendingDatatypeRename).toBeNull() + + const state = store.getState() + const variables = state.project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type).toEqual({ definition: 'user-data-type', value: 'NewDT' }) + expect(variables[1].type).toEqual({ + definition: 'array', + value: 'ARRAY [0..4] OF NewDT', + data: { + baseType: { definition: 'user-data-type', value: 'NewDT' }, + dimensions: [{ dimension: '0..4' }], + }, + }) + expect(state.project.data.configurations.resource.globalVariables[0].type).toEqual({ + definition: 'user-data-type', + value: 'NewDT', + }) + const chassis = state.project.data.dataTypes.find((d) => d.name === 'Chassis') + expect(chassis?.derivation === 'structure' && chassis.variable[0].type.value).toBe('NewDT') + const bank = state.project.data.dataTypes.find((d) => d.name === 'Bank') + expect(bank?.derivation === 'array' && bank.baseType.value).toBe('NewDT') + + // The type itself was renamed and the old file queued for deletion. + expect(state.project.data.dataTypes.map((d) => d.name)).toEqual(['NewDT', 'Chassis', 'Bank']) + expect(state.pendingDeletions).toContain('datatypes/OldDT.dt') + }) + + it('confirm flags every affected container file dirty', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const files = store.getState().files + expect(files['Main'].saved).toBe(false) + expect(files['Resource'].saved).toBe(false) + expect(files['Chassis'].saved).toBe(false) + expect(files['Bank'].saved).toBe(false) + expect(store.getState().workspace.editingState).toBe('unsaved') + }) + + it('cancel leaves the store completely untouched', async () => { + const before = store.getState() + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(false) + const result = await promise + + expect(result).toEqual({ ok: false, cancelled: true, message: 'Rename cancelled' }) + const after = store.getState() + expect(after.pendingDatatypeRename).toBeNull() + // Same object references — no slice was written at all. + expect(after.project).toBe(before.project) + expect(after.files).toBe(before.files) + expect(after.tabs).toBe(before.tabs) + expect(after.pendingDeletions).toBe(before.pendingDeletions) + }) + + it('skips the modal when nothing references the type', async () => { + store.getState().datatypeActions.create({ name: 'Lonely', derivation: 'enumerated' }) + const result = await store.getState().datatypeActions.rename('Lonely', 'Hermit') + + expect(result).toEqual({ ok: true }) + expect(store.getState().pendingDatatypeRename).toBeNull() + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toContain('Hermit') + }) + + it('skips the reference scan on a no-op rename to the identical name', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'OldDT') + + expect(result.ok).toBe(true) + expect(store.getState().pendingDatatypeRename).toBeNull() + // References untouched — there was nothing to propagate. + const variables = store.getState().project.data.pous[0].interface?.variables ?? [] + expect(variables[0].type.value).toBe('OldDT') + }) + + it('rejects an invalid new name before opening the modal', async () => { + const result = await store.getState().datatypeActions.rename('OldDT', 'bad name') + + expect(result.ok).toBe(false) + expect(store.getState().pendingDatatypeRename).toBeNull() + }) + + it('rejects a second rename while one is awaiting confirmation', async () => { + const first = store.getState().datatypeActions.rename('OldDT', 'NewDT') + const pendingBefore = store.getState().pendingDatatypeRename + + store.getState().datatypeActions.create({ name: 'Other', derivation: 'structure' }) + store.getState().projectActions.updateDatatype('Other', { + name: 'Other', + derivation: 'structure', + variable: [{ name: 'f', type: { definition: 'user-data-type', value: 'Chassis' } }], + }) + const second = await store.getState().datatypeActions.rename('Chassis', 'Frame') + + expect(second.ok).toBe(false) + expect(second.message).toBe('Another data type rename is awaiting confirmation') + // The first request's resolver is untouched and still completes. + expect(store.getState().pendingDatatypeRename).toBe(pendingBefore) + store.getState().datatypeActions.respondToPendingRename(true) + const result = await first + expect(result).toEqual({ ok: true }) + expect(store.getState().project.data.dataTypes.map((d) => d.name)).toContain('NewDT') + }) + + it('respondToPendingRename without a pending request is a no-op', () => { + const before = store.getState() + store.getState().datatypeActions.respondToPendingRename(true) + expect(store.getState()).toBe(before) + }) + + it('regenerates the code-mode variables buffer when the affected POU is the active editor', async () => { + // pouActions.create left Main as the active editor. + expect(store.getState().editor.meta.name).toBe('Main') + store.getState().editorActions.updateModelVariablesForName('Main', { + display: 'code', + code: ' VAR\n motor : OldDT;\n END_VAR', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const code = getCodeBuffer('Main') + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('regenerates the buffer of a stored (non-active) POU model', async () => { + // Make something else the active editor so Main only lives in editors[]. + store.getState().datatypeActions.create({ name: 'Scratch', derivation: 'structure' }) + expect(store.getState().editor.meta.name).toBe('Scratch') + store.getState().editorActions.updateModelVariablesForName('Main', { + display: 'code', + code: ' VAR\n motor : OldDT;\n END_VAR', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const code = getCodeBuffer('Main') + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('leaves table-mode variable views alone', async () => { + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + expect(getVariableView('Main')?.display).toBe('table') + }) + + it('regenerates the .dt code buffer of an affected data type', async () => { + store.getState().editorActions.updateModelStructureForName('Chassis', { + display: 'code', + code: 'TYPE\n Chassis : STRUCT\n front : OldDT;\n END_STRUCT;\nEND_TYPE\n', + }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + await promise + + const model = store.getState().editorActions.getEditorFromEditors('Chassis') + const code = + model?.type === 'plc-datatype' && model.structure.display === 'code' ? model.structure.code : undefined + expect(code).toContain('NewDT') + expect(code).not.toContain('OldDT') + }) + + it('tolerates an affected POU without an editor model', async () => { + store.getState().projectActions.createPou({ + type: 'program', + data: { + language: 'st', + name: 'Headless', + variables: [directRef('m', 'OldDT')], + body: { language: 'st', value: '' }, + documentation: '', + }, + }) + store.getState().fileActions.addFile({ name: 'Headless', type: 'program', filePath: 'Headless' }) + + const promise = store.getState().datatypeActions.rename('OldDT', 'NewDT') + store.getState().datatypeActions.respondToPendingRename(true) + const result = await promise + + expect(result).toEqual({ ok: true }) + const headless = store.getState().project.data.pous.find((p) => p.name === 'Headless') + expect(headless?.interface?.variables[0].type.value).toBe('NewDT') + }) + }) + // ----------------------------------------------------------------------- // duplicate // ----------------------------------------------------------------------- @@ -1681,12 +1969,12 @@ describe('createSharedSlice', () => { expect(history.future[0].dataTypes).toEqual([edited]) }) - it('rename keeps the history and undo restores content under the new name', () => { + it('rename keeps the history and undo restores content under the new name', async () => { const initial = getColorsDataType() store.getState().snapshotActions.pushToHistory('Colors', { variables: [], body: null, dataTypes: [initial] }) store.getState().projectActions.updateDatatype('Colors', edited) - expect(store.getState().datatypeActions.rename('Colors', 'Palette').ok).toBe(true) + expect((await store.getState().datatypeActions.rename('Colors', 'Palette')).ok).toBe(true) expect(store.getState().undoRedo['Colors']).toBeUndefined() expect(store.getState().undoRedo['Palette'].past).toHaveLength(1) diff --git a/src/frontend/store/slices/project/slice.ts b/src/frontend/store/slices/project/slice.ts index 21a00faf0..a36466dfe 100644 --- a/src/frontend/store/slices/project/slice.ts +++ b/src/frontend/store/slices/project/slice.ts @@ -32,6 +32,7 @@ import { } from '../../../../middleware/shared/utils/iec-address/registry' import type { TargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' import { resolveTargetCapabilities } from '../../../../middleware/shared/utils/target-capabilities' +import { renameDataTypeInDataType, renameDataTypeInVariableType } from '../../../utils/data-type-references' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { isLegalIdentifier } from '../../../utils/keywords' @@ -1140,6 +1141,25 @@ const createProjectSlice: StateCreator = }), ) }, + propagateDatatypeRename: (oldName, newName) => { + setState( + produce((slice: ProjectSlice) => { + for (const pou of slice.project.data.pous) { + for (const variable of pou.interface?.variables ?? []) { + const nextType = renameDataTypeInVariableType(variable.type, oldName, newName) + if (nextType) variable.type = nextType + } + } + for (const variable of slice.project.data.configurations.resource.globalVariables) { + const nextType = renameDataTypeInVariableType(variable.type, oldName, newName) + if (nextType) variable.type = nextType + } + slice.project.data.dataTypes = slice.project.data.dataTypes.map( + (dataType) => renameDataTypeInDataType(dataType, oldName, newName) ?? dataType, + ) + }), + ) + }, createArrayDimension: ({ name, derivation: _derivation }) => { setState( produce((slice: ProjectSlice) => { diff --git a/src/frontend/store/slices/project/types.ts b/src/frontend/store/slices/project/types.ts index 015d48e5e..a29a86c5c 100644 --- a/src/frontend/store/slices/project/types.ts +++ b/src/frontend/store/slices/project/types.ts @@ -218,8 +218,13 @@ export type ProjectActions = { deleteDatatype: (name: string) => void updateDatatype: (name: string, data?: PLCDataType) => void /** Rename + queue the old `datatypes/.dt` path for deletion - * (model: `updatePouName`). Reference propagation is DOPE-536. */ + * (model: `updatePouName`). Reference propagation is + * `propagateDatatypeRename`, driven by `datatypeActions.rename`. */ updateDatatypeName: (oldName: string, newName: string) => void + /** Rewrite every reference to data type `oldName` (POU variables, global + * variables, other data types' fields / array base types) to `newName`. + * Does not touch the type's own entry — `updateDatatypeName` owns that. */ + propagateDatatypeRename: (oldName: string, newName: string) => void createArrayDimension: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => void rearrangeStructureVariables: (args: { associatedDataType?: string; rowId: number; newIndex: number }) => void applyDatatypeSnapshot: (name: string, data: PLCDataType) => void diff --git a/src/frontend/store/slices/shared/slice.ts b/src/frontend/store/slices/shared/slice.ts index 1435e28ab..78f9c810b 100644 --- a/src/frontend/store/slices/shared/slice.ts +++ b/src/frontend/store/slices/shared/slice.ts @@ -2,6 +2,8 @@ import { produce } from 'immer' import { StateCreator } from 'zustand' import { isValidIecIdentifier } from '../../../../middleware/shared/utils/ethercat' +import { findAllReferencesToDataType } from '../../../utils/data-type-references' +import type { DataTypeReferenceImpactAnalysis } from '../../../utils/data-type-references/types' import { parseIecStringToVariables } from '../../../utils/generate-iec-string-to-variables' import { generateIecVariablesToString } from '../../../utils/generate-iec-variables-to-string' import { syncNodesWithVariables, syncNodesWithVariablesFBD } from '../../../utils/graphical/sync-nodes-with-variables' @@ -95,6 +97,51 @@ function collidesWithUnparsedDataTypeFile(state: SharedRootState, name: string): : { ok: true } } +/** + * Post-propagation bookkeeping for a confirmed data type rename: + * + * 1. Flag every touched container's file dirty — single-file save and the + * close-project check read these flags, and the propagated content + * would otherwise be silently dropped on disk. + * 2. Regenerate code-mode variable buffers of affected POUs. `sanitizePou` + * persists `editor.variable.code` as the authoritative variables block, + * so a stale buffer would resurrect the old type name on save. + * 3. Regenerate the `.dt` code buffers of affected data types — committing + * a stale buffer (commitCode → updateDatatype) would do the same. + */ +function syncAfterDatatypePropagation(state: SharedRootState, impact: DataTypeReferenceImpactAnalysis): void { + const dirtyFiles = new Set() + const affectedPous = new Set() + const affectedDatatypes = new Set() + for (const ref of impact.references) { + // Global variables persist through the Resource entry in the file slice. + dirtyFiles.add(ref.kind === 'global-variable' ? 'Resource' : ref.container) + if (ref.kind === 'pou-variable') affectedPous.add(ref.container) + if (ref.kind === 'data-type-field' || ref.kind === 'data-type-base-type') affectedDatatypes.add(ref.container) + } + for (const name of dirtyFiles) { + state.sharedWorkspaceActions.handleFileAndWorkspaceSavedState(name) + } + + // No-op for types whose code view isn't active. + for (const datatypeName of affectedDatatypes) { + state.projectActions.regenerateDatatypeText(datatypeName) + } + + for (const pouName of affectedPous) { + const model = state.editor.meta.name === pouName ? state.editor : state.editors.find((e) => e.meta.name === pouName) + if (!model || (model.type !== 'plc-textual' && model.type !== 'plc-graphical')) continue + if (model.variable.display !== 'code') continue + const pou = state.project.data.pous.find((p) => p.name === pouName) + /* istanbul ignore next -- defensive: a pou-variable reference implies the POU exists */ + if (!pou) continue + state.editorActions.updateModelVariablesForName(pouName, { + display: 'code', + code: generateIecVariablesToString(pou.interface?.variables ?? []), + }) + } +} + function renameElement( state: SharedRootState, oldName: string, @@ -139,6 +186,7 @@ function renameElement( const createSharedSlice: StateCreator = (setState, getState) => ({ undoRedo: {}, + pendingDatatypeRename: null, pouActions: { create: ({ type, name, language }) => { @@ -310,7 +358,7 @@ const createSharedSlice: StateCreator = (s delete: (name) => deleteElement(getState(), name, (n) => getState().projectActions.deleteDatatype(n)), - rename: (oldName, newName) => { + rename: async (oldName, newName) => { const state = getState() // Includes the type being renamed: a case-only change writes the // new file and then deletes the old path — the same file where @@ -324,22 +372,57 @@ const createSharedSlice: StateCreator = (s const datatype = state.project.data.dataTypes.find((d) => d.name === oldName) if (!datatype) return { ok: false, message: 'Data type not found' } + // renameElement validates too, but checked up front so the impact + // modal never opens for a rename that would fail afterwards. + const nameCheck = validateElementName(newName) + if (!nameCheck.ok) return nameCheck + // Fold pending code-view edits in first, so the rename doesn't - // regenerate over them. + // regenerate over them — and so the reference scan sees them. const reconcile = state.projectActions.reconcileDatatypeText(oldName) if (!reconcile.ok) return { ok: false, message: reconcile.message } - const result = renameElement(state, oldName, newName, () => { + if (newName !== oldName) { + const freshState = getState() + const impact = findAllReferencesToDataType( + oldName, + freshState.project.data.pous, + freshState.project.data.configurations.resource.globalVariables, + freshState.project.data.dataTypes, + ) + if (impact.totalReferences > 0) { + // Overwriting a pending request would drop its resolver and strand + // the first caller's await forever (e.g. Enter + blur double-fire). + if (getState().pendingDatatypeRename) { + return { ok: false, message: 'Another data type rename is awaiting confirmation' } + } + const confirmed = await new Promise((resolve) => { + setState({ pendingDatatypeRename: { oldName, newName, impact, resolve } }) + }) + if (!confirmed) return { ok: false, cancelled: true, message: 'Rename cancelled' } + getState().projectActions.propagateDatatypeRename(oldName, newName) + syncAfterDatatypePropagation(getState(), impact) + } + } + + const result = renameElement(getState(), oldName, newName, () => { // Renames via the dedicated action so the old .dt path gets // queued for deletion — a plain updateDatatype would strand // the old file on disk. - state.projectActions.updateDatatypeName(oldName, newName) + getState().projectActions.updateDatatypeName(oldName, newName) }) // Only after renameElement are the type and its model both keyed by newName. if (result.ok) getState().projectActions.regenerateDatatypeText(newName) return result }, + respondToPendingRename: (confirmed) => { + const pending = getState().pendingDatatypeRename + if (!pending) return + setState({ pendingDatatypeRename: null }) + pending.resolve(confirmed) + }, + duplicate: (sourceName, newName) => { const state = getState() const source = state.project.data.dataTypes.find((d) => d.name === sourceName) diff --git a/src/frontend/store/slices/shared/types.ts b/src/frontend/store/slices/shared/types.ts index bf936b634..83b96a869 100644 --- a/src/frontend/store/slices/shared/types.ts +++ b/src/frontend/store/slices/shared/types.ts @@ -7,6 +7,7 @@ import type { PLCVariable, ProjectMeta, } from '../../../../middleware/shared/ports/types' +import type { DataTypeReferenceImpactAnalysis } from '../../../utils/data-type-references/types' import type { AISlice } from '../ai' import type { ConsoleSlice } from '../console' import type { DeviceSlice } from '../device' @@ -93,11 +94,30 @@ export type PouActions = { duplicate: (sourceName: string, newName: string) => SharedResponse } +export type DatatypeRenameResponse = SharedResponse & { + /** True when the user declined the reference-impact modal — a user choice, not an error. */ + cancelled?: boolean +} + +/** A rename waiting on the reference-impact modal. `resolve` releases the + * `datatypeActions.rename` await; the modal fires it via + * `datatypeActions.respondToPendingRename`. */ +export type PendingDatatypeRename = { + oldName: string + newName: string + impact: DataTypeReferenceImpactAnalysis + resolve: (confirmed: boolean) => void +} + export type DatatypeActions = { create: (args: { name: string; derivation: 'array' | 'enumerated' | 'structure' }) => SharedResponse deleteRequest: (name: string) => void delete: (name: string) => SharedResponse - rename: (oldName: string, newName: string) => SharedResponse + /** Async: a rename of a referenced type awaits the impact modal before + * propagating the new name into every reference. Cancel = no state change. */ + rename: (oldName: string, newName: string) => Promise + /** Confirm (`true`) or cancel (`false`) the pending rename's impact modal. */ + respondToPendingRename: (confirmed: boolean) => void duplicate: (sourceName: string, newName: string) => SharedResponse } @@ -178,6 +198,7 @@ export type SharedWorkspaceActions = { export type SharedSlice = { undoRedo: Record + pendingDatatypeRename: PendingDatatypeRename | null pouActions: PouActions datatypeActions: DatatypeActions serverActions: ServerActions diff --git a/src/frontend/utils/__tests__/data-type-references.test.ts b/src/frontend/utils/__tests__/data-type-references.test.ts new file mode 100644 index 000000000..9a8a58918 --- /dev/null +++ b/src/frontend/utils/__tests__/data-type-references.test.ts @@ -0,0 +1,254 @@ +import type { PLCDataType, PLCPou, PLCVariable, PLCVariableType } from '../../../middleware/shared/ports/types' +import { + findAllReferencesToDataType, + GLOBAL_VARIABLES_CONTAINER, + renameDataTypeInDataType, + renameDataTypeInVariableType, + variableTypeReferencesDataType, +} from '../data-type-references' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const directType = (typeName: string): PLCVariableType => ({ definition: 'user-data-type', value: typeName }) + +const arrayType = (typeName: string, dimensions: string[] = ['0..4']): PLCVariableType => ({ + definition: 'array', + value: `ARRAY [${dimensions.join(', ')}] OF ${typeName}`, + data: { + baseType: { definition: 'user-data-type', value: typeName }, + dimensions: dimensions.map((dimension) => ({ dimension })), + }, +}) + +const baseType = (value = 'INT'): PLCVariableType => ({ definition: 'base-type', value }) + +const makeVariable = (name: string, type: PLCVariableType): PLCVariable => ({ + name, + class: 'local', + type, + location: '', + documentation: '', +}) + +const makePou = (name: string, variables: PLCVariable[]): PLCPou => ({ + name, + pouType: 'program', + interface: { variables }, + body: { language: 'st', value: '' }, + documentation: '', +}) + +// --------------------------------------------------------------------------- +// variableTypeReferencesDataType +// --------------------------------------------------------------------------- + +describe('variableTypeReferencesDataType', () => { + it('matches a direct user-data-type reference case-insensitively', () => { + expect(variableTypeReferencesDataType(directType('MotorDef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(directType('motordef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(directType('Other'), 'MotorDef')).toBe(false) + }) + + it('matches an array base type reference', () => { + expect(variableTypeReferencesDataType(arrayType('MotorDef'), 'MotorDef')).toBe(true) + expect(variableTypeReferencesDataType(arrayType('Other'), 'MotorDef')).toBe(false) + }) + + it('ignores arrays without structured data', () => { + const lossy: PLCVariableType = { definition: 'array', value: 'ARRAY [0..4] OF MotorDef' } + expect(variableTypeReferencesDataType(lossy, 'MotorDef')).toBe(false) + }) + + it('ignores arrays of base types', () => { + const ints: PLCVariableType = { + definition: 'array', + value: 'ARRAY [0..4] OF INT', + data: { baseType: { definition: 'base-type', value: 'INT' }, dimensions: [{ dimension: '0..4' }] }, + } + expect(variableTypeReferencesDataType(ints, 'MotorDef')).toBe(false) + }) + + it('ignores base-type and derived references', () => { + expect(variableTypeReferencesDataType(baseType(), 'MotorDef')).toBe(false) + expect(variableTypeReferencesDataType({ definition: 'derived', value: 'MotorDef' }, 'MotorDef')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// findAllReferencesToDataType +// --------------------------------------------------------------------------- + +describe('findAllReferencesToDataType', () => { + const pous: PLCPou[] = [ + makePou('Main', [ + makeVariable('motor', directType('MotorDef')), + makeVariable('motors', arrayType('motordef')), + makeVariable('plain', baseType()), + ]), + makePou('Aux', [makeVariable('other', directType('Unrelated'))]), + { name: 'NoInterface', pouType: 'program', body: { language: 'st', value: '' }, documentation: '' }, + ] + + const globalVariables: PLCVariable[] = [ + { ...makeVariable('gMotor', directType('MotorDef')), class: 'global' }, + { ...makeVariable('gPlain', baseType()), class: 'global' }, + ] + + const dataTypes: PLCDataType[] = [ + { name: 'MotorDef', derivation: 'structure', variable: [{ name: 'speed', type: baseType() }] }, + { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('MotorDef') }, + { name: 'rear', type: arrayType('MotorDef') }, + { name: 'id', type: baseType() }, + ], + }, + { + name: 'MotorBank', + derivation: 'array', + baseType: directType('MotorDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }, + { name: 'Mode', derivation: 'enumerated', values: [{ description: 'Auto' }] }, + ] + + it('collects references from POU variables, globals, and other data types', () => { + const impact = findAllReferencesToDataType('MotorDef', pous, globalVariables, dataTypes) + + expect(impact.totalReferences).toBe(6) + expect(impact.references).toEqual([ + { kind: 'pou-variable', container: 'Main', variableName: 'motor' }, + { kind: 'pou-variable', container: 'Main', variableName: 'motors' }, + { kind: 'global-variable', container: GLOBAL_VARIABLES_CONTAINER, variableName: 'gMotor' }, + { kind: 'data-type-field', container: 'Chassis', variableName: 'front' }, + { kind: 'data-type-field', container: 'Chassis', variableName: 'rear' }, + { kind: 'data-type-base-type', container: 'MotorBank' }, + ]) + }) + + it('aggregates counts by container and by reference kind', () => { + const impact = findAllReferencesToDataType('MotorDef', pous, globalVariables, dataTypes) + + expect(Array.from(impact.byPou.entries())).toEqual([ + ['Main', 2], + [GLOBAL_VARIABLES_CONTAINER, 1], + ['Chassis', 2], + ['MotorBank', 1], + ]) + expect(Array.from(impact.byEditorType.entries())).toEqual([ + ['POU variables', 2], + ['global variables', 1], + ['data types', 3], + ]) + }) + + it('returns an empty analysis when nothing references the type', () => { + const impact = findAllReferencesToDataType('Ghost', pous, globalVariables, dataTypes) + + expect(impact.totalReferences).toBe(0) + expect(impact.byPou.size).toBe(0) + expect(impact.byEditorType.size).toBe(0) + expect(impact.references).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// renameDataTypeInVariableType +// --------------------------------------------------------------------------- + +describe('renameDataTypeInVariableType', () => { + it('renames a direct reference and keeps the rest of the type', () => { + expect(renameDataTypeInVariableType(directType('motordef'), 'MotorDef', 'DriveDef')).toEqual({ + definition: 'user-data-type', + value: 'DriveDef', + }) + }) + + it('renames an array base type and rebuilds the display value', () => { + const next = renameDataTypeInVariableType(arrayType('MotorDef', ['0..4', '1..2']), 'MotorDef', 'DriveDef') + expect(next).toEqual({ + definition: 'array', + value: 'ARRAY [0..4, 1..2] OF DriveDef', + data: { + baseType: { definition: 'user-data-type', value: 'DriveDef' }, + dimensions: [{ dimension: '0..4' }, { dimension: '1..2' }], + }, + }) + }) + + it('returns null for types that do not reference the old name', () => { + expect(renameDataTypeInVariableType(directType('Other'), 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInVariableType(arrayType('Other'), 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInVariableType(baseType(), 'MotorDef', 'DriveDef')).toBeNull() + expect( + renameDataTypeInVariableType({ definition: 'array', value: 'ARRAY [0..4] OF MotorDef' }, 'MotorDef', 'DriveDef'), + ).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// renameDataTypeInDataType +// --------------------------------------------------------------------------- + +describe('renameDataTypeInDataType', () => { + it('renames only the structure fields that reference the type', () => { + const chassis: PLCDataType = { + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('MotorDef') }, + { name: 'id', type: baseType() }, + ], + } + expect(renameDataTypeInDataType(chassis, 'MotorDef', 'DriveDef')).toEqual({ + name: 'Chassis', + derivation: 'structure', + variable: [ + { name: 'front', type: directType('DriveDef') }, + { name: 'id', type: baseType() }, + ], + }) + }) + + it('renames an array data type base type', () => { + const bank: PLCDataType = { + name: 'MotorBank', + derivation: 'array', + baseType: directType('MotorDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + } + expect(renameDataTypeInDataType(bank, 'MotorDef', 'DriveDef')).toEqual({ + name: 'MotorBank', + derivation: 'array', + baseType: directType('DriveDef'), + initialValue: '', + dimensions: [{ dimension: '1..8' }], + }) + }) + + it('returns null when nothing references the type', () => { + const unrelatedStruct: PLCDataType = { + name: 'Point', + derivation: 'structure', + variable: [{ name: 'x', type: baseType() }], + } + const unrelatedArray: PLCDataType = { + name: 'Ints', + derivation: 'array', + baseType: baseType(), + initialValue: '', + dimensions: [{ dimension: '0..1' }], + } + const mode: PLCDataType = { name: 'Mode', derivation: 'enumerated', values: [{ description: 'Auto' }] } + + expect(renameDataTypeInDataType(unrelatedStruct, 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInDataType(unrelatedArray, 'MotorDef', 'DriveDef')).toBeNull() + expect(renameDataTypeInDataType(mode, 'MotorDef', 'DriveDef')).toBeNull() + }) +}) diff --git a/src/frontend/utils/data-type-references.ts b/src/frontend/utils/data-type-references.ts new file mode 100644 index 000000000..5596f87bd --- /dev/null +++ b/src/frontend/utils/data-type-references.ts @@ -0,0 +1,139 @@ +import type { PLCDataType, PLCPou, PLCVariable, PLCVariableType } from '../../middleware/shared/ports/types' +import type { + DataTypeReferenceImpactAnalysis, + DataTypeReferenceKind, + DataTypeReferenceLocation, +} from './data-type-references/types' + +/** Container label used for references declared in the global variables table. */ +export const GLOBAL_VARIABLES_CONTAINER = 'Global Variables' + +// IEC identifiers are case-insensitive — same rule as the store's data type name checks. +const nameMatches = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase() + +const KIND_GROUP: Record = { + 'pou-variable': 'POU variables', + 'global-variable': 'global variables', + 'data-type-field': 'data types', + 'data-type-base-type': 'data types', +} + +/** True when `type` references the data type `typeName` — directly or as an array base type. */ +export function variableTypeReferencesDataType(type: PLCVariableType, typeName: string): boolean { + if (type.definition === 'user-data-type') { + return nameMatches(type.value, typeName) + } + if (type.definition === 'array') { + const baseType = type.data?.baseType + return baseType !== undefined && baseType.definition === 'user-data-type' && nameMatches(baseType.value, typeName) + } + return false +} + +/** + * Find every place `typeName` is referenced as a type: POU variables, global + * variables, other structures' fields, and other array data types' base types. + * Mirrors `findAllReferencesToVariable` and returns the same analysis shape so + * the rename impact modal renders it unchanged. + */ +export function findAllReferencesToDataType( + typeName: string, + pous: PLCPou[], + globalVariables: PLCVariable[], + dataTypes: PLCDataType[], +): DataTypeReferenceImpactAnalysis { + const references: DataTypeReferenceLocation[] = [] + + pous.forEach((pou) => { + ;(pou.interface?.variables ?? []).forEach((variable) => { + if (variableTypeReferencesDataType(variable.type, typeName)) { + references.push({ kind: 'pou-variable', container: pou.name, variableName: variable.name }) + } + }) + }) + + globalVariables.forEach((variable) => { + if (variableTypeReferencesDataType(variable.type, typeName)) { + references.push({ kind: 'global-variable', container: GLOBAL_VARIABLES_CONTAINER, variableName: variable.name }) + } + }) + + dataTypes.forEach((dataType) => { + if (dataType.derivation === 'structure') { + dataType.variable.forEach((field) => { + if (variableTypeReferencesDataType(field.type, typeName)) { + references.push({ kind: 'data-type-field', container: dataType.name, variableName: field.name }) + } + }) + } else if (dataType.derivation === 'array') { + if (variableTypeReferencesDataType(dataType.baseType, typeName)) { + references.push({ kind: 'data-type-base-type', container: dataType.name }) + } + } + }) + + const byPou = new Map() + const byEditorType = new Map() + references.forEach((ref) => { + byPou.set(ref.container, (byPou.get(ref.container) ?? 0) + 1) + const group = KIND_GROUP[ref.kind] + byEditorType.set(group, (byEditorType.get(group) ?? 0) + 1) + }) + + return { + totalReferences: references.length, + byPou, + byEditorType, + references, + } +} + +/** + * Rewrite a reference to `oldName` inside a variable type, or return `null` + * when the type doesn't reference it. + */ +export function renameDataTypeInVariableType( + type: PLCVariableType, + oldName: string, + newName: string, +): PLCVariableType | null { + if (type.definition === 'user-data-type' && nameMatches(type.value, oldName)) { + return { ...type, value: newName } + } + if (type.definition === 'array' && type.data) { + const { baseType, dimensions } = type.data + if (baseType.definition === 'user-data-type' && nameMatches(baseType.value, oldName)) { + const dims = dimensions.map((d) => d.dimension).join(', ') + return { + ...type, + // `value` is what variable serialization emits — rebuild it or the + // saved declaration keeps the old base type name. + value: `ARRAY [${dims}] OF ${newName}`, + data: { ...type.data, baseType: { ...baseType, value: newName } }, + } + } + } + return null +} + +/** + * Rewrite references to `oldName` inside another data type (structure fields, + * array base type), or return `null` when nothing references it. + */ +export function renameDataTypeInDataType(dataType: PLCDataType, oldName: string, newName: string): PLCDataType | null { + if (dataType.derivation === 'structure') { + let changed = false + const fields = dataType.variable.map((field) => { + const nextType = renameDataTypeInVariableType(field.type, oldName, newName) + if (!nextType) return field + changed = true + return { ...field, type: nextType } + }) + return changed ? { ...dataType, variable: fields } : null + } + if (dataType.derivation === 'array') { + const nextBaseType = renameDataTypeInVariableType(dataType.baseType, oldName, newName) + return nextBaseType ? { ...dataType, baseType: nextBaseType } : null + } + return null +} diff --git a/src/frontend/utils/data-type-references/types.ts b/src/frontend/utils/data-type-references/types.ts new file mode 100644 index 000000000..e26b5d232 --- /dev/null +++ b/src/frontend/utils/data-type-references/types.ts @@ -0,0 +1,13 @@ +import type { ReferenceImpactAnalysis } from '../variable-references/types' + +export type DataTypeReferenceKind = 'pou-variable' | 'global-variable' | 'data-type-field' | 'data-type-base-type' + +export type DataTypeReferenceLocation = { + kind: DataTypeReferenceKind + /** POU name, referencing data type name, or the global-variables table label. */ + container: string + /** Declaring variable / structure field name; absent for an array data type's base type. */ + variableName?: string +} + +export type DataTypeReferenceImpactAnalysis = ReferenceImpactAnalysis diff --git a/src/frontend/utils/variable-references/types.ts b/src/frontend/utils/variable-references/types.ts index 6c066809c..6f96416e8 100644 --- a/src/frontend/utils/variable-references/types.ts +++ b/src/frontend/utils/variable-references/types.ts @@ -10,9 +10,9 @@ export type VariableReferenceLocation = { columnEnd?: number } -export type ReferenceImpactAnalysis = { +export type ReferenceImpactAnalysis = { totalReferences: number byPou: Map byEditorType: Map - references: VariableReferenceLocation[] + references: Location[] }