Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 273 additions & 97 deletions docs/save_load_widget.md

Large diffs are not rendered by default.

439 changes: 439 additions & 0 deletions examples/configuration_management_system.py

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions js/src/components/ui/LoadSave.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,9 @@ export const LoadSave: React.FC = () => {
disableLoad,
disableSave,
disableSaveAs,
disableRename,
disableSaveReason,
disableRenameReason,
defaultNewItemName,
searchResults,
actionNote,
Expand Down Expand Up @@ -762,9 +764,10 @@ export const LoadSave: React.FC = () => {

{selectedItemId && (
<button
className="dropdown-item"
className={`dropdown-item ${disableRename ? 'disabled' : ''}`}
onClick={() => setIsRenameDialogOpen(true)}
title="Rename current item"
disabled={disableRename}
title={disableRename && disableRenameReason ? disableRenameReason : "Rename current item"}
>
Rename
</button>
Expand Down
42 changes: 25 additions & 17 deletions js/src/components/widgets/LoadSaveContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ interface LoadSaveContextType {
disableLoad: boolean;
disableSave: boolean;
disableSaveAs: boolean;
disableRename: boolean;
disableSaveReason: string | null;
disableRenameReason: string | null;
defaultNewItemName: string;
searchResults: Item[];
actionNote: string | null;
Expand Down Expand Up @@ -56,13 +58,16 @@ export const LoadSaveProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const [disableLoad] = useModelState<boolean>("disable_load");
const [disableSave] = useModelState<boolean>("disable_save");
const [disableSaveAs] = useModelState<boolean>("disable_save_as");
const [disableRename] = useModelState<boolean>("disable_rename");
const [disableSaveReason] = useModelState<string | null>("disable_save_reason");
const [disableRenameReason] = useModelState<string | null>("disable_rename_reason");
const [defaultNewItemName] = useModelState<string>("default_new_item_name");

// Action triggers
const [doSave, setDoSave] = useModelState<boolean>("do_save");
const [doReset, setDoReset] = useModelState<boolean>("do_reset");
const [doLoad, setDoLoad] = useModelState<boolean>("do_load");
const [doRename, setDoRename] = useModelState<boolean>("do_rename");

// Response states
const [actionNote, setActionNote] = useModelState<string | null>("action_note");
Expand All @@ -72,6 +77,9 @@ export const LoadSaveProvider: React.FC<{ children: React.ReactNode }> = ({ chil
const [newItemName, setNewItemName] = useModelState<string | null>("new_item_name");
const [createNewItem, setCreateNewItem] = useModelState<boolean>("create_new_item");
const [isSaveAs, setIsSaveAs] = useModelState<boolean>("is_save_as");

// Save as target name
const [saveAsTargetName, setSaveAsTargetName] = useModelState<string | null>("save_as_target_name");

// Local state for tracking operations
const [saveInProgress, setSaveInProgress] = React.useState(false);
Expand All @@ -82,6 +90,10 @@ export const LoadSaveProvider: React.FC<{ children: React.ReactNode }> = ({ chil
// Local state for search results (no Python sync)
const [clientSearchResults, setClientSearchResults] = useState<Item[]>([]);

// Rename operations
const [renameItemId, setRenameItemId] = useModelState<string | null>("rename_item_id");
const [renameNewName, setRenameNewName] = useModelState<string | null>("rename_new_name");

// Handle effects for save operations
useEffect(() => {
if (saveInProgress && !doSave) {
Expand Down Expand Up @@ -156,33 +168,27 @@ export const LoadSaveProvider: React.FC<{ children: React.ReactNode }> = ({ chil

const handleSaveAsWithId = useCallback((itemId: string) => {
saveTargetRef.current = itemId;
// Find the target item's label to pass to the backend
const targetItem = items?.find((item: Item) => item.id === itemId);
if (targetItem) {
// Set the target name for the backend to use
setSaveAsTargetName(targetItem.label);
}
setSelectedItemId(itemId);
setSaveInProgress(true);
setDoSave(true);
}, [setSelectedItemId, setDoSave]);
}, [setSelectedItemId, setDoSave, items, setSaveAsTargetName]);

// Search handler only updates local state, no Python communication
const handleSearch = useCallback((query: string) => {
setCurrentSearchQuery(query);
}, []);

const handleRename = useCallback((itemId: string, newName: string) => {
const updatedItems = items?.map(item =>
item.id === itemId ? { ...item, label: newName } : item
);

if (updatedItems) {
setItems(updatedItems);

// Also update local search results to reflect the name change
setClientSearchResults(prev =>
prev.map(item => item.id === itemId ? { ...item, label: newName } : item)
);
}

setActionNote(`Item renamed to "${newName}"`);
setSuccessStatus(true);
}, [items, setItems, setActionNote, setSuccessStatus]);
setRenameItemId(itemId);
setRenameNewName(newName);
setDoRename(true);
}, [setRenameItemId, setRenameNewName, setDoRename]);

const value = {
// States
Expand All @@ -193,7 +199,9 @@ export const LoadSaveProvider: React.FC<{ children: React.ReactNode }> = ({ chil
disableLoad: disableLoad || false,
disableSave: disableSave || false,
disableSaveAs: disableSaveAs || false,
disableRename: disableRename || false,
disableSaveReason,
disableRenameReason,
defaultNewItemName: defaultNewItemName || "New Item",
searchResults: clientSearchResults,
actionNote,
Expand Down
266 changes: 266 additions & 0 deletions js/src/components/widgets/__tests__/LoadSaveContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/**
* @jest-environment jsdom
*/

import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

// Mock the LoadSaveContext functionality
describe('LoadSaveContext - New Features', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('Rename Functionality Tests', () => {
it('should handle rename operation state management', () => {
// Test that rename operations properly manage state
const mockRenameOperation = {
itemId: 'test-item',
newName: 'New Test Name',
success: true,
message: 'Item renamed successfully'
};

expect(mockRenameOperation.itemId).toBe('test-item');
expect(mockRenameOperation.newName).toBe('New Test Name');
expect(mockRenameOperation.success).toBe(true);
});

it('should validate rename callback signature', () => {
// Test that rename callbacks follow the expected signature
const mockRenameCallback = jest.fn((itemId: string, newName: string) =>
[true, `Renamed ${itemId} to ${newName}`]
);

const result = mockRenameCallback('item1', 'New Name');
expect(mockRenameCallback).toHaveBeenCalledWith('item1', 'New Name');
expect(result).toEqual([true, 'Renamed item1 to New Name']);
});
});

describe('Disable Controls Tests', () => {
it('should handle granular disable states', () => {
const disableState = {
disableLoad: false,
disableSave: true,
disableSaveAs: false,
disableRename: true,
disableSaveReason: 'Read-only mode',
disableRenameReason: 'Insufficient permissions'
};

expect(disableState.disableSave).toBe(true);
expect(disableState.disableRename).toBe(true);
expect(disableState.disableSaveReason).toBe('Read-only mode');
expect(disableState.disableRenameReason).toBe('Insufficient permissions');
});

it('should validate disable reason messages', () => {
const reasons = [
'Contact administrator for permissions',
'System maintenance in progress',
'Read-only access',
'Feature temporarily disabled'
];

reasons.forEach(reason => {
expect(typeof reason).toBe('string');
expect(reason.length).toBeGreaterThan(0);
});
});
});

describe('Save-As Target Name Tests', () => {
it('should handle enhanced save callback signatures', () => {
// Test enhanced save callback with target_name parameter
const enhancedSaveCallback = jest.fn((force: boolean, targetName?: string) => {
if (targetName) {
return [true, `Saved as ${targetName}`];
}
return [true, 'Saved successfully'];
});

// Test regular save
let result = enhancedSaveCallback(false);
expect(result).toEqual([true, 'Saved successfully']);

// Test save-as with target name
result = enhancedSaveCallback(false, 'Target Configuration');
expect(result).toEqual([true, 'Saved as Target Configuration']);
});

it('should handle legacy save callback compatibility', () => {
// Test legacy save callback without target_name parameter
const legacySaveCallback = jest.fn((force: boolean) =>
[true, 'Legacy save successful']
);

const result = legacySaveCallback(false);
expect(legacySaveCallback).toHaveBeenCalledWith(false);
expect(result).toEqual([true, 'Legacy save successful']);
});

it('should handle target name lookup from items', () => {
const items = [
{ id: 'item1', label: 'Configuration A' },
{ id: 'item2', label: 'Configuration B' },
{ id: 'item3', label: 'Production Config' }
];

const findTargetName = (selectedId: string) => {
const item = items.find(item => item.id === selectedId);
return item ? item.label : null;
};

expect(findTargetName('item1')).toBe('Configuration A');
expect(findTargetName('item2')).toBe('Configuration B');
expect(findTargetName('nonexistent')).toBe(null);
});
});

describe('New Item Callback Enhancement Tests', () => {
it('should handle enhanced new item callback with is_save_as parameter', () => {
const enhancedNewCallback = jest.fn((name: string, isSaveAs = false) => {
const newItem = { id: `new-${Date.now()}`, label: name };
const message = isSaveAs ? `Saved as ${name}` : `Created ${name}`;
return [newItem, true, message];
});

// Test regular new item creation
let result = enhancedNewCallback('New Item');
expect(result[1]).toBe(true); // success
expect(result[2]).toBe('Created New Item'); // message

// Test save-as operation
result = enhancedNewCallback('Save As Copy', true);
expect(result[1]).toBe(true); // success
expect(result[2]).toBe('Saved as Save As Copy'); // message
});
});

describe('State Management Tests', () => {
it('should handle operation state cleanup', () => {
const operationState = {
doSave: false,
doRename: false,
renameItemId: null,
renameNewName: null,
saveAsTargetName: null
};

// Simulate operation completion
operationState.doSave = false;
operationState.doRename = false;
operationState.renameItemId = null;
operationState.renameNewName = null;
operationState.saveAsTargetName = null;

expect(operationState.doSave).toBe(false);
expect(operationState.doRename).toBe(false);
expect(operationState.renameItemId).toBe(null);
expect(operationState.renameNewName).toBe(null);
expect(operationState.saveAsTargetName).toBe(null);
});

it('should handle callback signature detection', () => {
const detectCallbackSignature = (callback: Function) => {
const params = callback.toString().match(/\(([^)]*)\)/)?.[1] || '';
return params.includes('target_name') || params.includes('targetName');
};

// Enhanced callback
const enhancedCallback = (force: boolean, targetName?: string) => {};
expect(detectCallbackSignature(enhancedCallback)).toBe(true);

// Legacy callback
const legacyCallback = (force: boolean) => {};
expect(detectCallbackSignature(legacyCallback)).toBe(false);
});
});

describe('Error Handling Tests', () => {
it('should handle callback errors gracefully', () => {
const errorCallback = jest.fn(() => {
throw new Error('Callback failed');
});

const safeCallCallback = (callback: Function, ...args: any[]) => {
try {
return callback(...args);
} catch (error) {
return [false, error instanceof Error ? error.message : 'Unknown error'];
}
};

const result = safeCallCallback(errorCallback);
expect(result).toEqual([false, 'Callback failed']);
});

it('should validate item existence for operations', () => {
const items = [
{ id: 'item1', label: 'Item 1' },
{ id: 'item2', label: 'Item 2' }
];

const validateItem = (itemId: string) => {
return items.some(item => item.id === itemId);
};

expect(validateItem('item1')).toBe(true);
expect(validateItem('item2')).toBe(true);
expect(validateItem('nonexistent')).toBe(false);
});
});

describe('Integration Tests', () => {
it('should handle complete save-as workflow', () => {
const workflow = {
currentConfig: { id: 'current', label: 'Current Config', data: { value: 123 } },
targetName: 'New Config Copy',
saveAsTargetName: null as string | null,
selectedItemId: null as string | null
};

// Set target name for save-as
workflow.saveAsTargetName = workflow.targetName;

// Simulate save callback with target name
const saveResult = workflow.saveAsTargetName
? [true, `Saved as ${workflow.saveAsTargetName}`]
: [true, 'Saved successfully'];

expect(saveResult).toEqual([true, 'Saved as New Config Copy']);

// Clean up state
workflow.saveAsTargetName = null;
expect(workflow.saveAsTargetName).toBe(null);
});

it('should handle complete rename workflow', () => {
const workflow = {
selectedItem: { id: 'item1', label: 'Original Name' },
newName: 'Updated Name',
renameItemId: null as string | null,
renameNewName: null as string | null
};

// Set rename operation
workflow.renameItemId = workflow.selectedItem.id;
workflow.renameNewName = workflow.newName;

// Simulate rename callback
const renameResult = workflow.renameItemId && workflow.renameNewName
? [true, `Renamed ${workflow.selectedItem.label} to ${workflow.renameNewName}`]
: [false, 'Invalid rename operation'];

expect(renameResult).toEqual([true, 'Renamed Original Name to Updated Name']);

// Clean up state
workflow.renameItemId = null;
workflow.renameNewName = null;
expect(workflow.renameItemId).toBe(null);
expect(workflow.renameNewName).toBe(null);
});
});
});
Loading