Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d879267
feat: add suffix unit dependency checking and fix async cleanup
BunnyBea83 Jul 21, 2026
b3b1b34
feat: add suffix unit dependency checking and fix async cleanup
BunnyBea83 Jul 21, 2026
4cb3205
feat: add DB layer methods for cascading suffix deletions
BunnyBea83 Jul 21, 2026
ff4f60e
feat: enhance conversion/unit deletion with dependency validation
BunnyBea83 Jul 21, 2026
e41845c
feat: enhance UI warnings for suffix unit deletions
BunnyBea83 Jul 21, 2026
041ca55
Revert: Omit DB layer methods for cascading suffix deletions.
BunnyBea83 Jul 27, 2026
26a0c8b
Refactor: Robust Suffix Unit detection.
BunnyBea83 Jul 27, 2026
0887fe1
Feat: Add safe unit deletion
BunnyBea83 Jul 27, 2026
5e4e541
Refactor: Suffix unit deletion instead of hiding
BunnyBea83 Jul 27, 2026
d0df4e3
Feat:
BunnyBea83 Aug 4, 2026
3077314
Feat:
BunnyBea83 Aug 4, 2026
b1ff92f
Refactor:
BunnyBea83 Aug 4, 2026
2355470
Feat:
BunnyBea83 Aug 4, 2026
aade23e
Refactor: Linted Code
BunnyBea83 Aug 5, 2026
4a5c31b
Merge branch 'OpenEnergyDashboard:development' into BunnyBea83/issue1448
BunnyBea83 Sep 17, 2026
8f8ea5c
Refactor: PR cleanup and SQL implementation.
BunnyBea83 Sep 17, 2026
1c095a4
Refactor: redoCik and orphan unit detection
BunnyBea83 Sep 19, 2026
2bcf354
Refactor: UI display and Unit Tests.
BunnyBea83 Sep 20, 2026
67061e9
Refactor: Naming conventions and formatting
BunnyBea83 Oct 5, 2026
f25a64e
Refactor: SQL naming conventions
BunnyBea83 Oct 5, 2026
542302d
Refactor: redoCik usage
BunnyBea83 Oct 5, 2026
24c0592
Refactor: Orphan Check
BunnyBea83 Oct 5, 2026
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
252 changes: 213 additions & 39 deletions src/client/app/components/conversion/EditConversionModalComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import ConfirmActionModalComponent from '../ConfirmActionModalComponent';
import { showErrorNotification, showSuccessNotification } from '../../utils/notifications';
import TooltipMarkerComponent from '../TooltipMarkerComponent';
import { SimpleUnsavedWarningComponent } from '../SimpleUnsavedWarningComponent';

import { conversionArrow } from '../../utils/conversionArrow';

interface EditConversionModalComponentProps {
show: boolean;
Expand Down Expand Up @@ -95,11 +95,19 @@ export default function EditConversionModalComponent(props: EditConversionModalC
return source?.typeOfUnit === UnitType.meter;
};

// Determine whether the selected unit is a Suffix unit
// Including Suffix input with unit type Unit and Suffix is filled in
const isSuffixRelated = (unit?: UnitData): boolean => {
return !!unit && (
unit.typeOfUnit === UnitType.suffix || unit.suffix !== ''
);
};

// Determine whether the selected source or destination is a suffix unit
const isSuffixUsed = () => {
const source = unitDataById[state.sourceId];
const dest = unitDataById[state.destinationId];
return source?.typeOfUnit === UnitType.suffix || dest?.typeOfUnit === UnitType.suffix;
return isSuffixRelated(source) || isSuffixRelated(dest);
};

/**
Expand Down Expand Up @@ -128,12 +136,15 @@ export default function EditConversionModalComponent(props: EditConversionModalC
}
return count;
};

// Performs checks to warn the admin of the impact deleting a conversion will have on meter units and possible graphing units.
const checkState = async () => {
const source = unitDataById[state.sourceId];
const dest = unitDataById[state.destinationId];
const msgElements: React.ReactNode[] = [];
let cancel = false;
// Use to label OED-created units
let suffixTypeUnitsToDeleteIds: Set<number> = new Set();

// Meter source orphan check
if (source.typeOfUnit === UnitType.meter) {
Expand Down Expand Up @@ -205,34 +216,159 @@ export default function EditConversionModalComponent(props: EditConversionModalC
}

// Unit-to-unit orphan check
if (source.typeOfUnit === UnitType.unit && dest.typeOfUnit === UnitType.unit) {
if (source.typeOfUnit === UnitType.unit && dest.typeOfUnit === UnitType.unit
&& !isSuffixRelated(source) && !isSuffixRelated(dest)) {
// Find conversions to destination or source of bidirectional conversion
const destConversions = conversionDetails.filter(conversion =>
(conversion.destinationId === dest.id) ||
(conversion.bidirectional && conversion.sourceId === dest.id)
);

// Exclude conversion being deleted
// Find remaining connections to destination unit afterward
const remainingDestConversions = destConversions.filter(conversion =>
!(conversion.sourceId === source.id && conversion.destinationId === dest.id)
);

// Display units that will be orphaned by conversion deletion
if (remainingDestConversions.length === 0) {
msgElements.push(
<div key="unit-orphan">
<span className="bold">{translate('conversion.delete.unit.orphan')} </span>
"{dest.name}".
<ul>
<li>"{dest.name}"</li>
</ul>
</div>
);
}
// Suffix unit to Suffix Unit deletion
} else if (isSuffixRelated(source) || isSuffixRelated(dest)) {
const suffixUnit = isSuffixRelated(source) ? source : dest;
// Find all conversions involving this suffix unit (as source or destination)
const suffixUnitConversions = conversionDetails.filter(c =>
c.sourceId === suffixUnit.id ||
(c.bidirectional && c.destinationId === suffixUnit.id)
);

// Find conversions involving the given suffix unit
// that connect to another suffix-related unit.
const conversionsToDelete = suffixUnitConversions.filter(c => {
Comment thread
huss marked this conversation as resolved.
const otherId = c.sourceId === suffixUnit.id ? c.destinationId : c.sourceId;
return isSuffixRelated(unitDataById[otherId]);
});

// Find the suffix-related units of conversionsToDelete.
// Return the units that may be cascade deleted.
const suffixTypeUnitsToDelete = conversionsToDelete
.map(c => {
const otherId = c.sourceId === suffixUnit.id ? c.destinationId : c.sourceId;
return unitDataById[otherId];
});
// Populate computed units
suffixTypeUnitsToDeleteIds = new Set(suffixTypeUnitsToDelete.map(u => u.id))
// Check for meters/groups using affected suffix units
const affectedSuffixUnitIds = new Set(suffixTypeUnitsToDeleteIds);
// Also check the main suffix unit
affectedSuffixUnitIds.add(suffixUnit.id);

// Check meters using these units (as unitId or defaultGraphicUnit)
const affectedMetersList = Object.values(meterDataById).filter(meter =>
affectedSuffixUnitIds.has(meter.unitId) ||
affectedSuffixUnitIds.has(meter.defaultGraphicUnit)
);

// Check groups using these units (as defaultGraphicUnit)
const affectedGroupsList = Object.values(groupDataById).filter(group =>
affectedSuffixUnitIds.has(group.defaultGraphicUnit)
);

// Display dependency warnings, affected meters and groups if any
if (affectedMetersList.length > 0 || affectedGroupsList.length > 0) {
msgElements.push(
<div key="suffix-dependencies-warning">
<span className="bold">{translate('conversion.delete.suffix.dependencies.warning')}</span>
{affectedMetersList.length > 0 && (
<div>
<span className="bold">{translate('conversion.delete.suffix.meters.affected')}:</span>
<ul>
{affectedMetersList.map(m => (
<li key={m.id}>"{m.name}"</li>
))}
</ul>
</div>
)}
{affectedGroupsList.length > 0 && (
<div>
<span className="bold">{translate('conversion.delete.suffix.groups.affected')}:</span>
<ul>
{affectedGroupsList.map(g => (
<li key={g.id}>"{g.name}"</li>
))}
</ul>
</div>
)}
</div>
);
}

// Display units that will be cascade deleted
if (suffixTypeUnitsToDelete.length > 0) {
msgElements.push(
<div key="suffix-units-to-hide">
<span className="bold">{translate('conversion.delete.suffix.units.to.delete')}:</span>
<ul>
{suffixTypeUnitsToDelete.map(u => (
<li key={u.id}>"{u.name}" </li>
))}
</ul>
</div>
);
}

// List the conversions that will be cascade deleted
if (conversionsToDelete.length > 0) {
msgElements.push(
<div key="suffix-conversions-to-delete">
<span className="bold">{translate('conversion.delete.suffix.conversions.to.delete')}:</span>
<ul>
{conversionsToDelete.map((conversion, idx) => {
const sourceName = unitDataById[conversion.sourceId].name;
const destinationName = unitDataById[conversion.destinationId].name;
const arrow = conversionArrow(conversion.bidirectional);
return (
<li key={`${conversion.sourceId}-${conversion.destinationId}-${idx}`}>
"{sourceName}"{arrow}"{destinationName}"
</li>
);
})}
</ul>
</div>
);
}
}

// Only run simulation if the previous orphan check passed and it's unit-to-unit
if (source.typeOfUnit !== UnitType.suffix && dest.typeOfUnit === UnitType.unit && !cancel) {
// Run simulation if the previous orphan check passed and it's not cancelled
// Now supports suffix units since simulation has been enhanced
if (isSuffixRelated(source) && dest.typeOfUnit === UnitType.unit && !cancel) {
try {
const result = await triggerSimulate({
sourceId: state.sourceId,
destinationId: state.destinationId
}).unwrap();

// Orphaned Suffix unit check
if (result.potentiallyOrphanedUnits?.length > 0) {
msgElements.push(
<div key="potentially-orphaned-units">
<span className="bold">{translate('conversion.delete.unit.orphan')}:</span>
<ul>
{result.potentiallyOrphanedUnits.map((u) => (
<li key={u.id}>"{u.name}"</li>
))}
</ul>
</div>
);
}

// Orphaned groups check
const orphanedGroups = result.affectedGroups?.filter(group => group.orphaned);
if (orphanedGroups.length > 0) {
Expand All @@ -249,6 +385,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC
cancel = true;
} else {
// Group meters by lostUnits
// Display all meters that are losing the same set of units
const meterLossMap = new Map<string, string[]>();
result.affectedMeters.forEach(meter => {
const key = JSON.stringify([...meter.lostUnits].sort());
Expand All @@ -257,26 +394,39 @@ export default function EditConversionModalComponent(props: EditConversionModalC
}
meterLossMap.get(key)!.push(meter.meterName);
});
meterLossMap.forEach((meterNames, lostUnitsKey) => {
const lostUnits = JSON.parse(lostUnitsKey).map(Number);
if (meterLossMap.size > 0) {
msgElements.push(
<div key={`meters-${lostUnitsKey}`}>
<span className="bold">{translate('conversion.delete.meter.affected')}: </span>
<div style={{ marginLeft: 16 }}>
{meterNames.map(name => (
<div key={name}>"{name}"</div>
))}
<div key={"meters-affected"}>
<div className="lost-units-section-heading">
<span className="bold">{translate('conversion.delete.meter.affected')}: </span>
</div>
<span className="bold">{translate('conversion.delete.lost.units')}: </span>
{lostUnits.map((id: number, i: number) => (
<span key={id}>"{unitDataById[id]?.name || id}"{i < lostUnits.length - 1 ? ', ' : ''}</span>
))}
<br /><br />
{Array.from(meterLossMap.entries()).map(([lostUnitsKey, meterNames]) => {
const lostUnits = JSON.parse(lostUnitsKey).map(Number);
return (
<div key={`meters-${lostUnitsKey}`} className="lost-units-group">
<div style={{ marginLeft: 16 }}>
{meterNames.map(name => (
<div key={name}>"{name}"</div>
))}
</div>
<span className="bold">{translate('conversion.delete.lost.units')}: </span>
<ul>
{lostUnits.map((id: number, i: number) => (
<li key={id}>
"{unitDataById[id]?.name || id}"
{suffixTypeUnitsToDeleteIds.has(id) ? '(created by OED)' : ''}
</li>
))}
</ul>
</div>
);
})}
</div>
);
});
}

// Group non-orphaned groups by lostUnits
// Display all groups losing the same set of units
const groupLossMap = new Map<string, string[]>();
const nonOrphanedGroups = result.affectedGroups?.filter(group => !group.orphaned);
nonOrphanedGroups.forEach(group => {
Expand All @@ -286,24 +436,36 @@ export default function EditConversionModalComponent(props: EditConversionModalC
}
groupLossMap.get(key)!.push(group.groupName);
});
groupLossMap.forEach((groupNames, lostUnitsKey) => {
const lostUnits = JSON.parse(lostUnitsKey).map(Number);
if (groupLossMap.size > 0) {
msgElements.push(
<div key={`groups-${lostUnitsKey}`}>
<span className="bold">{translate('conversion.delete.group.affected')}: </span>
<div style={{ marginLeft: 16 }}>
{groupNames.map(name => (
<div key={name}>"{name}"</div>
))}
<div key={"groups-affected"}>
<div className="lost-units-section-heading">
<span className="bold">{translate('conversion.delete.group.affected')}: </span>
</div>
<span className="bold">{translate('conversion.delete.lost.units')}: </span>
{lostUnits.map((id: number, i: number) => (
<span key={id}>"{unitDataById[id]?.name || id}"{i < lostUnits.length - 1 ? ', ' : ''}</span>
))}
<br /><br />
{Array.from(groupLossMap.entries()).map(([lostUnitsKey, groupNames]) => {
const lostUnits = JSON.parse(lostUnitsKey).map(Number);
return (
<div key={`groups-${lostUnitsKey}`} className="lost-units-group">
<div style={{ marginLeft: 16 }}>
{groupNames.map(name => (
<div key={name}>"{name}"</div>
))}
</div>
<span className="bold">{translate('conversion.delete.lost.units')}: </span>
<ul>
{lostUnits.map((id: number, i: number) => (
<li key={id}>
"{unitDataById[id]?.name || id}"
{suffixTypeUnitsToDeleteIds.has(id) ? ' (created by OED)' : ''}
</li>
))}
</ul>
</div>
);
})}
</div>
);
});
}

// After you get result from simulateDeleteConversion check default graphic units
const metersLostDefault: number[] = [];
Expand Down Expand Up @@ -342,10 +504,12 @@ export default function EditConversionModalComponent(props: EditConversionModalC
);
}
});
// Save which meters.groups need their default unit cleared
setMetersWithLostDefault(metersLostDefault);
setGroupsWithLostDefault(groupsLostDefault);
}
} catch (e) {
// If the simulation fails block the delete
msgElements.push(
<div key="simulation-error">
{translate('conversion.delete.simulation.error')}
Expand All @@ -354,25 +518,32 @@ export default function EditConversionModalComponent(props: EditConversionModalC
cancel = true;
}
}

// Process the appropriate modal based on the cancel conditions set above
if (cancel) {
msgElements.push(
<div key="restricted">
<br />
{translate('conversion.delete.restricted')}
<span className="bold">
{translate('conversion.delete.restricted')}
</span>
</div>
);
setDeleteConfirmationMessage(msgElements);
handleCancelModalOpen();
} else {
// Confirm deletion if no blocking issues
msgElements.push(
Comment thread
huss marked this conversation as resolved.
<div key="final-confirm">
{translate('conversion.delete.conversion')} [{props.conversionIdentifier}] ?
<br />
<span className="bold">
{translate('conversion.delete.conversion')} [{props.conversionIdentifier}] ?
</span>
</div>
);
setDeleteConfirmationMessage(msgElements);
handleDeleteConfirmationModalOpen();
}

};
/* Confirm Delete Modal */
// Separate from state comment to keep everything related to the warning confirmation modal together
Expand All @@ -399,14 +570,16 @@ export default function EditConversionModalComponent(props: EditConversionModalC
// Closes the warning modal
// Do not call the handler function because we do not want to open the parent modal
setShowDeleteConfirmationModal(false);

// Bundle the conversions, meters, and groups to delete
Comment thread
huss marked this conversation as resolved.
// Backend will clear the references as part of the same delete
const payload = {
sourceId: state.sourceId,
destinationId: state.destinationId,
meterIds: metersWithLostDefault,
groupIds: groupsWithLostDefault
};
deleteConversion(payload)
// Send delete request
.unwrap()
.then(() => {
// Show source/destination identifiers (not numeric IDs)
Expand All @@ -416,6 +589,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC
', ' + translate('conversion.destination') + ' "' + unitDataById[payload.destinationId]?.identifier + '")'
);
}).catch(error => {
// Show any backend error messages alongside identifiers
showErrorNotification(
translate('conversion.delete.failure') +
' (' + translate('conversion.source') + ' "' + unitDataById[payload.sourceId]?.identifier + '"' +
Expand Down
Loading