diff --git a/frontend/src/framework/GuiMessageBroker.ts b/frontend/src/framework/GuiMessageBroker.ts index 4c9242a70..0824807dc 100644 --- a/frontend/src/framework/GuiMessageBroker.ts +++ b/frontend/src/framework/GuiMessageBroker.ts @@ -4,6 +4,8 @@ import { isDevMode } from "@lib/utils/devMode"; import { Size2D } from "@lib/utils/geometry"; import { Vec2 } from "@lib/utils/vec2"; +import { UnsavedChangesAction } from "./types/unsavedChangesAction"; + export enum LeftDrawerContent { ModuleSettings = "ModuleSettings", ModulesList = "ModulesList", @@ -27,6 +29,7 @@ export enum GuiState { EditDataChannelConnections = "editDataChannelConnections", RightSettingsPanelWidthInPercent = "rightSettingsPanelWidthInPercent", AppInitialized = "appInitialized", + NumberOfUnsavedRealizationFilters = "numberOfUnsavedRealizationFilters", } export enum GuiEvent { @@ -43,6 +46,7 @@ export enum GuiEvent { DataChannelConnectionsChange = "dataChannelConnectionsChange", DataChannelNodeHover = "dataChannelNodeHover", DataChannelNodeUnhover = "dataChannelNodeUnhover", + UnsavedRealizationFilterSettingsAction = "unsavedRealizationFilterSettingsAction", } export type GuiEventPayloads = { @@ -75,6 +79,9 @@ export type GuiEventPayloads = { [GuiEvent.DataChannelNodeHover]: { connectionAllowed: boolean; }; + [GuiEvent.UnsavedRealizationFilterSettingsAction]: { + action: UnsavedChangesAction; + }; }; type GuiStateValueTypes = { @@ -87,6 +94,7 @@ type GuiStateValueTypes = { [GuiState.EditDataChannelConnections]: boolean; [GuiState.RightSettingsPanelWidthInPercent]: number; [GuiState.AppInitialized]: boolean; + [GuiState.NumberOfUnsavedRealizationFilters]: number; }; const defaultStates: Map = new Map(); @@ -98,12 +106,14 @@ defaultStates.set(GuiState.DataChannelConnectionLayerVisible, false); defaultStates.set(GuiState.DevToolsVisible, isDevMode()); defaultStates.set(GuiState.RightSettingsPanelWidthInPercent, 0); defaultStates.set(GuiState.AppInitialized, false); +defaultStates.set(GuiState.NumberOfUnsavedRealizationFilters, 0); const persistentStates: GuiState[] = [ GuiState.LeftSettingsPanelWidthInPercent, GuiState.DevToolsVisible, GuiState.RightSettingsPanelWidthInPercent, GuiState.RightDrawerContent, + GuiState.NumberOfUnsavedRealizationFilters, ]; export class GuiMessageBroker { diff --git a/frontend/src/framework/internal/components/EnsembleRealizationFilter/ensembleRealizationFilter.tsx b/frontend/src/framework/internal/components/EnsembleRealizationFilter/ensembleRealizationFilter.tsx index c40514668..402b0b2b6 100644 --- a/frontend/src/framework/internal/components/EnsembleRealizationFilter/ensembleRealizationFilter.tsx +++ b/frontend/src/framework/internal/components/EnsembleRealizationFilter/ensembleRealizationFilter.tsx @@ -211,33 +211,45 @@ export const EnsembleRealizationFilter: React.FC } } + const activeStyleClasses = { + "ring ring-opacity-100 shadow-lg": true, + "ring-blue-400 shadow-blue-400": !props.hasUnsavedSelections, + "ring-orange-400 shadow-orange-400": props.hasUnsavedSelections, + }; + const inactiveStyleClasses = { + "cursor-pointer ring-2": true, + "ring-opacity-100": !props.isAnotherFilterActive, + "ring-opacity-50 group hover:shadow-md hover:ring-opacity-75 transition-opacity": props.isAnotherFilterActive, + "ring-gray-300 shadow-gray-300 ": !props.hasUnsavedSelections, + "ring-orange-400 shadow-orange-400": props.hasUnsavedSelections, + "hover:shadow-blue-400 hover:shadow-lg shadow-md": !props.isAnotherFilterActive && props.hasUnsavedSelections, + "hover:ring-blue-400 hover:shadow-blue-400 hover:shadow-md": + !props.isAnotherFilterActive && !props.hasUnsavedSelections, + }; + const mainDivStyleClasses = props.isActive ? activeStyleClasses : inactiveStyleClasses; + return (
-
+
{props.ensembleName}
@@ -260,7 +272,13 @@ export const EnsembleRealizationFilter: React.FC />
-
+
= const nonCompactWidthAndHeightPx = 12; // Find the number of realizations that can fit in a row based on non-compact size, as factor of 5 - const candidateNumberOfRealizationsPerRow = Math.floor( - divSize.width / (nonCompactWidthAndHeightPx + nonCompactGapPx) + const candidateNumberOfRealizationsPerRow = Math.max( + 5, + Math.floor(divSize.width / (nonCompactWidthAndHeightPx + nonCompactGapPx)) ); const remainder = candidateNumberOfRealizationsPerRow % 5; const newNumberOfRealizationsPerRow = diff --git a/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx b/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx index eb3845813..358ecd53a 100644 --- a/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx +++ b/frontend/src/framework/internal/components/NavBar/rightNavBar.tsx @@ -2,6 +2,7 @@ import React from "react"; import { GuiState, RightDrawerContent, useGuiState } from "@framework/GuiMessageBroker"; import { Workbench } from "@framework/Workbench"; +import { Badge } from "@lib/components/Badge"; import { Button } from "@lib/components/Button"; import { resolveClassNames } from "@lib/utils/resolveClassNames"; import { FilterAlt, History } from "@mui/icons-material"; @@ -11,27 +12,24 @@ type RightNavBarProps = { }; export const RightNavBar: React.FC = (props) => { - const [drawerContent, setDrawerContent] = useGuiState( - props.workbench.getGuiMessageBroker(), - GuiState.RightDrawerContent + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + const [drawerContent, setDrawerContent] = useGuiState(guiMessageBroker, GuiState.RightDrawerContent); + const [numberOfUnsavedRealizationFilters] = useGuiState( + guiMessageBroker, + GuiState.NumberOfUnsavedRealizationFilters ); - const [rightSettingsPanelWidth, setRightSettingsPanelWidth] = useGuiState( - props.workbench.getGuiMessageBroker(), + guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent ); function ensureSettingsPanelIsVisible() { if (rightSettingsPanelWidth <= 5) { - setRightSettingsPanelWidth(15); + setRightSettingsPanelWidth(30); } } function handleRealizationFilterClick() { - if (rightSettingsPanelWidth > 0 && drawerContent === RightDrawerContent.RealizationFilterSettings) { - setRightSettingsPanelWidth(0); - return; - } ensureSettingsPanelIsVisible(); setDrawerContent(RightDrawerContent.RealizationFilterSettings); } @@ -49,7 +47,9 @@ export const RightNavBar: React.FC = (props) => { >
); diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx index 27752f348..5c2c0cfe1 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/private-components/RealizationFilterSettings/realizationFilterSettings.tsx @@ -1,7 +1,14 @@ import React from "react"; import { EnsembleIdent } from "@framework/EnsembleIdent"; -import { GuiState, RightDrawerContent, useGuiValue } from "@framework/GuiMessageBroker"; +import { + GuiEvent, + GuiEventPayloads, + GuiState, + RightDrawerContent, + useGuiState, + useGuiValue, +} from "@framework/GuiMessageBroker"; import { Workbench } from "@framework/Workbench"; import { useEnsembleSet } from "@framework/WorkbenchSession"; import { Drawer } from "@framework/internal/components/Drawer"; @@ -9,9 +16,9 @@ import { EnsembleRealizationFilter, EnsembleRealizationFilterSelections, } from "@framework/internal/components/EnsembleRealizationFilter"; +import { UnsavedChangesAction } from "@framework/types/unsavedChangesAction"; +import { countTrueValues } from "@framework/utils/objectUtils"; import { areParameterIdentStringToValueSelectionMapCandidatesEqual } from "@framework/utils/realizationFilterTypesUtils"; -import { Button } from "@lib/components/Button"; -import { Dialog } from "@lib/components/Dialog"; import { FilterAlt } from "@mui/icons-material"; import { isEqual } from "lodash"; @@ -19,11 +26,16 @@ import { isEqual } from "lodash"; export type RealizationFilterSettingsProps = { workbench: Workbench; onClose: () => void }; export const RealizationFilterSettings: React.FC = (props) => { - const drawerContent = useGuiValue(props.workbench.getGuiMessageBroker(), GuiState.RightDrawerContent); + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + const drawerContent = useGuiValue(guiMessageBroker, GuiState.RightDrawerContent); + const rightSettingsPanelWidth = useGuiValue(guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent); const ensembleSet = useEnsembleSet(props.workbench.getWorkbenchSession()); const realizationFilterSet = props.workbench.getWorkbenchSession().getRealizationFilterSet(); + const [, setNumberOfUnsavedRealizationFilters] = useGuiState( + guiMessageBroker, + GuiState.NumberOfUnsavedRealizationFilters + ); - const [dialogOpen, setDialogOpen] = React.useState(false); const [activeFilterEnsembleIdent, setActiveFilterEnsembleIdent] = React.useState(null); // Maps for keeping track of unsaved changes and filter selections @@ -37,6 +49,11 @@ export const RealizationFilterSettings: React.FC [ensembleIdentString: string]: EnsembleRealizationFilterSelections; }>({}); + // Set no active filter if the settings panel is closed + if (rightSettingsPanelWidth < 5 && activeFilterEnsembleIdent !== null) { + setActiveFilterEnsembleIdent(null); + } + // Create new maps if ensembles are added or removed const ensembleIdentStrings = ensembleSet.getEnsembleArr().map((ensemble) => ensemble.getIdent().toString()); if (!isEqual(ensembleIdentStrings, Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap))) { @@ -77,19 +94,99 @@ export const RealizationFilterSettings: React.FC } setEnsembleIdentStringHasUnsavedChangesMap(updatedHasUnsavedChangesMap); setEnsembleIdentStringToRealizationFilterSelectionsMap(updatedSelectionsMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(updatedHasUnsavedChangesMap)); } - function handleFilterSettingsClose() { - // Check if there are unsaved changes - const hasUnsavedChanges = Object.values(ensembleIdentStringHasUnsavedChangesMap).some( - (hasUnsavedChanges) => hasUnsavedChanges - ); - if (hasUnsavedChanges) { - setDialogOpen(true); - } else { - props.onClose(); - setActiveFilterEnsembleIdent(null); + const handleApplyAllClick = React.useCallback( + function handleApplyAllClick() { + // Apply all the unsaved changes state and reset the unsaved changes state + const resetHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = {}; + for (const ensembleIdentString in ensembleIdentStringToRealizationFilterSelectionsMap) { + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + const selections = ensembleIdentStringToRealizationFilterSelectionsMap[ensembleIdent.toString()]; + + // Apply the filter changes + realizationFilter.setFilterType(selections.filterType); + realizationFilter.setIncludeOrExcludeFilter(selections.includeOrExcludeFilter); + realizationFilter.setRealizationNumberSelections(selections.realizationNumberSelections); + realizationFilter.setParameterIdentStringToValueSelectionReadonlyMap( + selections.parameterIdentStringToValueSelectionReadonlyMap + ); + + // Run filtering + realizationFilter.runFiltering(); + + // Reset the unsaved changes state + resetHasUnsavedChangesMap[ensembleIdentString] = false; + } + + setEnsembleIdentStringHasUnsavedChangesMap(resetHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(0); + }, + [ + ensembleIdentStringToRealizationFilterSelectionsMap, + realizationFilterSet, + setNumberOfUnsavedRealizationFilters, + ] + ); + + const handleDiscardAllClick = React.useCallback( + function handleDiscardAllClick() { + // Discard all filter changes - i.e. reset the unsaved changes state + const resetSelectionsMap: { [ensembleIdentString: string]: EnsembleRealizationFilterSelections } = {}; + const resetHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = {}; + for (const ensembleIdentString in ensembleIdentStringToRealizationFilterSelectionsMap) { + const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); + const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); + + resetSelectionsMap[ensembleIdentString] = { + displayRealizationNumbers: realizationFilter.getFilteredRealizations(), + realizationNumberSelections: realizationFilter.getRealizationNumberSelections(), + parameterIdentStringToValueSelectionReadonlyMap: + realizationFilter.getParameterIdentStringToValueSelectionReadonlyMap(), + filterType: realizationFilter.getFilterType(), + includeOrExcludeFilter: realizationFilter.getIncludeOrExcludeFilter(), + }; + resetHasUnsavedChangesMap[ensembleIdentString] = false; + } + + setEnsembleIdentStringToRealizationFilterSelectionsMap(resetSelectionsMap); + setEnsembleIdentStringHasUnsavedChangesMap(resetHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(0); + }, + [ + ensembleIdentStringToRealizationFilterSelectionsMap, + realizationFilterSet, + setNumberOfUnsavedRealizationFilters, + ] + ); + + React.useEffect(() => { + function handleUnsavedChangesAction( + payload: GuiEventPayloads[GuiEvent.UnsavedRealizationFilterSettingsAction] + ) { + if (payload.action === UnsavedChangesAction.Save) { + handleApplyAllClick(); + setActiveFilterEnsembleIdent(null); + } else if (payload.action === UnsavedChangesAction.Discard) { + handleDiscardAllClick(); + setActiveFilterEnsembleIdent(null); + } } + + const removeUnsavedChangesActionHandler = guiMessageBroker.subscribeToEvent( + GuiEvent.UnsavedRealizationFilterSettingsAction, + handleUnsavedChangesAction + ); + + return () => { + removeUnsavedChangesActionHandler(); + }; + }, [guiMessageBroker, handleApplyAllClick, handleDiscardAllClick]); + + function handleFilterSettingsClose() { + props.onClose(); } function handleApplyClick(ensembleIdent: EnsembleIdent) { @@ -109,43 +206,14 @@ export const RealizationFilterSettings: React.FC realizationFilter.runFiltering(); // Reset the unsaved changes state - setEnsembleIdentStringHasUnsavedChangesMap({ - ...ensembleIdentStringHasUnsavedChangesMap, - [ensembleIdentString]: false, - }); + const newHasUnsavedChangesMap = { ...ensembleIdentStringHasUnsavedChangesMap, [ensembleIdentString]: false }; + setEnsembleIdentStringHasUnsavedChangesMap(newHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(newHasUnsavedChangesMap)); // Notify subscribers of change. props.workbench.getWorkbenchSession().notifyAboutEnsembleRealizationFilterChange(); } - function handleApplyAllClick() { - // Apply all the unsaved changes state and reset the unsaved changes state - const resetHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = {}; - for (const ensembleIdentString of Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap)) { - const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); - const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); - const selections = ensembleIdentStringToRealizationFilterSelectionsMap[ensembleIdent.toString()]; - - // Apply the filter changes - realizationFilter.setFilterType(selections.filterType); - realizationFilter.setIncludeOrExcludeFilter(selections.includeOrExcludeFilter); - realizationFilter.setRealizationNumberSelections(selections.realizationNumberSelections); - realizationFilter.setParameterIdentStringToValueSelectionReadonlyMap( - selections.parameterIdentStringToValueSelectionReadonlyMap - ); - - // Run filtering - realizationFilter.runFiltering(); - - // Reset the unsaved changes state - resetHasUnsavedChangesMap[ensembleIdentString] = false; - } - - setEnsembleIdentStringHasUnsavedChangesMap(resetHasUnsavedChangesMap); - setDialogOpen(false); - props.onClose(); - } - function handleDiscardClick(ensembleIdent: EnsembleIdent) { const ensembleIdentString = ensembleIdent.toString(); const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); @@ -162,36 +230,9 @@ export const RealizationFilterSettings: React.FC }); // Reset the unsaved changes state - setEnsembleIdentStringHasUnsavedChangesMap({ - ...ensembleIdentStringHasUnsavedChangesMap, - [ensembleIdentString]: false, - }); - } - - function handleDiscardAllClick() { - // Discard all filter changes - i.e. reset the unsaved changes state - const resetSelectionsMap: { [ensembleIdentString: string]: EnsembleRealizationFilterSelections } = {}; - const resetHasUnsavedChangesMap: { [ensembleIdentString: string]: boolean } = {}; - for (const ensembleIdentString of Object.keys(ensembleIdentStringToRealizationFilterSelectionsMap)) { - const ensembleIdent = EnsembleIdent.fromString(ensembleIdentString); - const realizationFilter = realizationFilterSet.getRealizationFilterForEnsembleIdent(ensembleIdent); - - resetSelectionsMap[ensembleIdentString] = { - displayRealizationNumbers: realizationFilter.getFilteredRealizations(), - realizationNumberSelections: realizationFilter.getRealizationNumberSelections(), - parameterIdentStringToValueSelectionReadonlyMap: - realizationFilter.getParameterIdentStringToValueSelectionReadonlyMap(), - filterType: realizationFilter.getFilterType(), - includeOrExcludeFilter: realizationFilter.getIncludeOrExcludeFilter(), - }; - resetHasUnsavedChangesMap[ensembleIdentString] = false; - } - - setEnsembleIdentStringToRealizationFilterSelectionsMap(resetSelectionsMap); - setEnsembleIdentStringHasUnsavedChangesMap(resetHasUnsavedChangesMap); - - setDialogOpen(false); - props.onClose(); + const newHasUnsavedChangesMap = { ...ensembleIdentStringHasUnsavedChangesMap, [ensembleIdentString]: false }; + setEnsembleIdentStringHasUnsavedChangesMap(newHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(newHasUnsavedChangesMap)); } function handleFilterChange(ensembleIdent: EnsembleIdent, selections: EnsembleRealizationFilterSelections) { @@ -215,10 +256,12 @@ export const RealizationFilterSettings: React.FC selections.includeOrExcludeFilter !== realizationFilter.getIncludeOrExcludeFilter(); // Update the unsaved changes state - setEnsembleIdentStringHasUnsavedChangesMap({ + const newHasUnsavedChangesMap = { ...ensembleIdentStringHasUnsavedChangesMap, [ensembleIdentString]: hasUnsavedChanges, - }); + }; + setEnsembleIdentStringHasUnsavedChangesMap(newHasUnsavedChangesMap); + setNumberOfUnsavedRealizationFilters(countTrueValues(newHasUnsavedChangesMap)); } function handleSetActiveEnsembleRealizationFilter(ensembleIdent: EnsembleIdent) { @@ -274,28 +317,6 @@ export const RealizationFilterSettings: React.FC ); })}
- { - setDialogOpen(false)} - title="Unsaved changes" - modal - showCloseCross={true} - actions={ -
- - -
- } - > - You have unsaved filter changes which are not applied to their respective ensemble yet. Do - you want to save the changes? -
- }
diff --git a/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx b/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx index a2a00d3bf..2fcf8d81c 100644 --- a/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx +++ b/frontend/src/framework/internal/components/RightSettingsPanel/rightSettingsPanel.tsx @@ -1,7 +1,10 @@ import React from "react"; -import { GuiState, useGuiState } from "@framework/GuiMessageBroker"; +import { GuiEvent, GuiState, useGuiState } from "@framework/GuiMessageBroker"; import { Workbench } from "@framework/Workbench"; +import { UnsavedChangesAction } from "@framework/types/unsavedChangesAction"; +import { Button } from "@lib/components/Button"; +import { Dialog } from "@lib/components/Dialog"; import { ModuleInstanceLog } from "./private-components/ModuleInstanceLog/moduleInstanceLog"; import { RealizationFilterSettings } from "./private-components/RealizationFilterSettings"; @@ -9,23 +12,70 @@ import { RealizationFilterSettings } from "./private-components/RealizationFilte type RightSettingsPanelProps = { workbench: Workbench }; export const RightSettingsPanel: React.FC = (props) => { - const [, setRightSettingsPanelWidth] = useGuiState( - props.workbench.getGuiMessageBroker(), - GuiState.RightSettingsPanelWidthInPercent + const guiMessageBroker = props.workbench.getGuiMessageBroker(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [, setRightSettingsPanelWidth] = useGuiState(guiMessageBroker, GuiState.RightSettingsPanelWidthInPercent); + const [numberOfUnsavedRealizationFilters] = useGuiState( + guiMessageBroker, + GuiState.NumberOfUnsavedRealizationFilters ); - function handleRealizationFilterSettingsClose() { + function handleOnClose() { + if (numberOfUnsavedRealizationFilters !== 0) { + setDialogOpen(true); + return; + } + + setRightSettingsPanelWidth(0); + } + + function handleDialogSaveClick() { + guiMessageBroker.publishEvent(GuiEvent.UnsavedRealizationFilterSettingsAction, { + action: UnsavedChangesAction.Save, + }); + setDialogOpen(false); setRightSettingsPanelWidth(0); } - function handleModuleInstanceLogClose() { + function handleDialogDiscardClick() { + guiMessageBroker.publishEvent(GuiEvent.UnsavedRealizationFilterSettingsAction, { + action: UnsavedChangesAction.Discard, + }); + setDialogOpen(false); setRightSettingsPanelWidth(0); } + function handleDialogCloseClick() { + guiMessageBroker.publishEvent(GuiEvent.UnsavedRealizationFilterSettingsAction, { + action: UnsavedChangesAction.Cancel, + }); + setDialogOpen(false); + } + return (
- - + + + + + +
+ } + > + You have unsaved realization filter changes which are not applied to their respective ensemble yet. Do + you want to save the changes? +
); }; diff --git a/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx b/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx index 746bfef1b..b9d4f004d 100644 --- a/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx +++ b/frontend/src/framework/internal/components/SettingsContentPanels/settingsContentPanels.tsx @@ -39,7 +39,7 @@ export const SettingsContentPanels: React.FC = (prop 100 - leftSettingsPanelWidth - rightSettingsPanelWidth, rightSettingsPanelWidth, ]} - minSizes={[300, 0, 300]} + minSizes={[300, 0, 400]} onSizesChange={handleResizablePanelsChange} > diff --git a/frontend/src/framework/types/unsavedChangesAction.ts b/frontend/src/framework/types/unsavedChangesAction.ts new file mode 100644 index 000000000..99d3f3448 --- /dev/null +++ b/frontend/src/framework/types/unsavedChangesAction.ts @@ -0,0 +1,5 @@ +export enum UnsavedChangesAction { + Save = "Save", + Discard = "Discard", + Cancel = "Cancel", +} diff --git a/frontend/src/framework/utils/objectUtils.ts b/frontend/src/framework/utils/objectUtils.ts new file mode 100644 index 000000000..e4abed1d0 --- /dev/null +++ b/frontend/src/framework/utils/objectUtils.ts @@ -0,0 +1,8 @@ +/** + * Check number of boolean values equal to true in object of string keys and boolean values + * + * Returns number of true values in object + */ +export function countTrueValues(obj: { [key: string]: boolean }): number { + return Object.values(obj).filter((value) => value).length; +} diff --git a/frontend/tests/unit/objectUtils.test.ts b/frontend/tests/unit/objectUtils.test.ts new file mode 100644 index 000000000..aad650f28 --- /dev/null +++ b/frontend/tests/unit/objectUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { countTrueValues } from "../../src/framework/utils/objectUtils"; + +describe("countTrueValues", () => { + it("should return 0 for an empty object", () => { + const result = countTrueValues({}); + expect(result).toBe(0); + }); + + it("should return 0 when all values are false", () => { + const result = countTrueValues({ a: false, b: false, c: false }); + expect(result).toBe(0); + }); + + it("should return the correct count of true values", () => { + const result = countTrueValues({ a: true, b: false, c: true }); + expect(result).toBe(2); + }); + + it("should return the correct count when all values are true", () => { + const result = countTrueValues({ a: true, b: true, c: true }); + expect(result).toBe(3); + }); + + it("should handle mixed true and false values correctly", () => { + const result = countTrueValues({ a: true, b: false, c: true, d: false }); + expect(result).toBe(2); + }); +});