diff --git a/chaoscenter/web/src/components/EditableStepName/EditableStepName.module.scss b/chaoscenter/web/src/components/EditableStepName/EditableStepName.module.scss new file mode 100644 index 00000000000..4e4623c782e --- /dev/null +++ b/chaoscenter/web/src/components/EditableStepName/EditableStepName.module.scss @@ -0,0 +1,41 @@ +.displayMode { + display: flex; + align-items: center; + gap: var(--spacing-small); + width: 100%; + min-height: 45px; + + .stepNameTextContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-tiny); + flex: 1; + } +} + +.editMode { + display: flex; + align-items: center; + gap: var(--spacing-small); + width: 100%; + min-height: 45px; + animation: fadeIn 0.2s ease-out; + + .stepNameTextInput { + flex: 1; + min-width: 200px; + margin-bottom: 0; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-2px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/chaoscenter/web/src/components/EditableStepName/EditableStepName.module.scss.d.ts b/chaoscenter/web/src/components/EditableStepName/EditableStepName.module.scss.d.ts new file mode 100644 index 00000000000..374eddd26f9 --- /dev/null +++ b/chaoscenter/web/src/components/EditableStepName/EditableStepName.module.scss.d.ts @@ -0,0 +1,13 @@ +declare namespace EditableStepNameModuleScssNamespace { + export interface IEditableStepNameModuleScss { + displayMode: string; + editMode: string; + fadeIn: string; + stepNameTextContainer: string; + stepNameTextInput: string; + } +} + +declare const EditableStepNameModuleScssModule: EditableStepNameModuleScssNamespace.IEditableStepNameModuleScss; + +export = EditableStepNameModuleScssModule; diff --git a/chaoscenter/web/src/components/EditableStepName/EditableStepName.tsx b/chaoscenter/web/src/components/EditableStepName/EditableStepName.tsx new file mode 100644 index 00000000000..84cda0bb35e --- /dev/null +++ b/chaoscenter/web/src/components/EditableStepName/EditableStepName.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { Color, FontVariation } from '@harnessio/design-system'; +import { Button, ButtonSize, ButtonVariation, Text, TextInput, useToaster } from '@harnessio/uicore'; +import css from './EditableStepName.module.scss'; + +export interface EditableStepNameProps { + stepName?: string; + faultName: string; + onSave: (newStepName: string) => Promise; + fontSize?: FontVariation; + showSubtitle?: boolean; + disabled?: boolean; +} + +export function EditableStepName({ + stepName, + faultName, + onSave, + fontSize = FontVariation.H5, + showSubtitle = true, + disabled = false +}: EditableStepNameProps): React.ReactElement { + const { showError } = useToaster(); + const [isEditing, setIsEditing] = React.useState(false); + const [editedValue, setEditedValue] = React.useState(''); + const [isSaving, setIsSaving] = React.useState(false); + + const displayName = stepName || faultName; + const shouldShowSubtitle = showSubtitle && stepName && stepName !== faultName; + + const handleEditStart = (): void => { + setEditedValue(displayName); + setIsEditing(true); + }; + + const handleSave = async (): Promise => { + if (editedValue.trim() && editedValue !== stepName) { + setIsSaving(true); + try { + await onSave(editedValue.trim()); + setIsEditing(false); + setEditedValue(''); + } catch (error) { + showError('Failed to update step name'); + } finally { + setIsSaving(false); + } + } else { + setIsEditing(false); + setEditedValue(''); + } + }; + + const handleCancel = (): void => { + setIsEditing(false); + setEditedValue(''); + }; + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + handleCancel(); + } + }; + + if (isEditing) { + return ( +
+ ) => setEditedValue(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + disabled={isSaving} + /> +
+ ); + } + + return ( +
+
+ {displayName} + {shouldShowSubtitle && ( + + {faultName} + + )} +
+ {!disabled && ( +
+ ); +} + +export default EditableStepName; diff --git a/chaoscenter/web/src/components/EditableStepName/__tests__/EditableStepName.test.tsx b/chaoscenter/web/src/components/EditableStepName/__tests__/EditableStepName.test.tsx new file mode 100644 index 00000000000..3048d28bec1 --- /dev/null +++ b/chaoscenter/web/src/components/EditableStepName/__tests__/EditableStepName.test.tsx @@ -0,0 +1,494 @@ +import '@testing-library/jest-dom/extend-expect'; +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TestWrapper } from 'utils/testUtils'; +import EditableStepName from '../EditableStepName'; + +const mockShowError = jest.fn(); + +jest.mock('@harnessio/uicore', () => ({ + ...jest.requireActual('@harnessio/uicore'), + useToaster: () => ({ + showError: mockShowError, + showSuccess: jest.fn(), + showWarning: jest.fn(), + clear: jest.fn() + }) +})); + +describe('EditableStepName', () => { + const mockOnSave = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const defaultProps = { + faultName: 'pod-delete', + onSave: mockOnSave + }; + + describe('Display Mode', () => { + test('renders fault name when no step name is provided', () => { + render( + + + + ); + + expect(screen.getByText('pod-delete')).toBeInTheDocument(); + }); + + test('renders step name when provided', () => { + render( + + + + ); + + expect(screen.getByText('Custom Step Name')).toBeInTheDocument(); + }); + + test('shows subtitle when step name differs from fault name and showSubtitle is true', () => { + render( + + + + ); + + expect(screen.getByText('Custom Step Name')).toBeInTheDocument(); + expect(screen.getByText('pod-delete')).toBeInTheDocument(); + }); + + test('does not show subtitle when step name equals fault name', () => { + render( + + + + ); + + const podDeleteElements = screen.getAllByText('pod-delete'); + // Should only render once, not as subtitle + expect(podDeleteElements).toHaveLength(1); + }); + + test('does not show subtitle when showSubtitle is false', () => { + render( + + + + ); + + expect(screen.getByText('Custom Step Name')).toBeInTheDocument(); + expect(screen.queryByText('pod-delete')).not.toBeInTheDocument(); + }); + + test('renders edit button when not disabled', () => { + render( + + + + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + test('does not render edit button when disabled', () => { + render( + + + + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + }); + + describe('Edit Mode', () => { + test('enters edit mode when edit button is clicked', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const editButton = screen.getByRole('button'); + await user.click(editButton); + + const input = screen.getByDisplayValue('Custom Step Name'); + expect(input).toBeInTheDocument(); + expect(input).toHaveFocus(); + }); + + test('displays current step name in input field', async () => { + const user = userEvent.setup(); + render( + + + + ); + + await user.click(screen.getByRole('button')); + + expect(screen.getByDisplayValue('Test Name')).toBeInTheDocument(); + }); + + test('displays fault name when no step name is provided', async () => { + const user = userEvent.setup(); + render( + + + + ); + + await user.click(screen.getByRole('button')); + + expect(screen.getByDisplayValue('pod-delete')).toBeInTheDocument(); + }); + + test('renders save and cancel buttons in edit mode', async () => { + const user = userEvent.setup(); + render( + + + + ); + + await user.click(screen.getByRole('button')); + + const buttons = screen.getAllByRole('button'); + // Should have 2 buttons: save (tick) and cancel (cross) + expect(buttons).toHaveLength(2); + }); + }); + + describe('Save Functionality', () => { + test('calls onSave with trimmed value when save button is clicked', async () => { + const user = userEvent.setup(); + mockOnSave.mockResolvedValue(undefined); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, ' New Name '); + + const buttons = screen.getAllByRole('button'); + const saveButton = buttons[0]; // tick button + await user.click(saveButton); + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledWith('New Name'); + }); + }); + + test('calls onSave when Enter key is pressed', async () => { + const user = userEvent.setup(); + mockOnSave.mockResolvedValue(undefined); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, 'New Name{Enter}'); + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledWith('New Name'); + }); + }); + + test('exits edit mode after successful save', async () => { + const user = userEvent.setup(); + mockOnSave.mockResolvedValue(undefined); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, 'New Name{Enter}'); + + await waitFor(() => { + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + }); + + test('does not call onSave when value is unchanged', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + + const buttons = screen.getAllByRole('button'); + const saveButton = buttons[0]; + await user.click(saveButton); + + expect(mockOnSave).not.toHaveBeenCalled(); + }); + + test('does not call onSave when value is empty or whitespace only', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Name'); + await user.clear(input); + await user.type(input, ' '); + + const buttons = screen.getAllByRole('button'); + const saveButton = buttons[0]; + await user.click(saveButton); + + expect(mockOnSave).not.toHaveBeenCalled(); + }); + + test('shows error toast when save fails', async () => { + const user = userEvent.setup(); + mockOnSave.mockRejectedValue(new Error('Save failed')); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, 'New Name{Enter}'); + + await waitFor(() => { + expect(mockShowError).toHaveBeenCalledWith('Failed to update step name'); + }); + }); + + test('remains in edit mode when save fails', async () => { + const user = userEvent.setup(); + mockOnSave.mockRejectedValue(new Error('Save failed')); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, 'New Name{Enter}'); + + await waitFor(() => { + expect(mockShowError).toHaveBeenCalled(); + }); + + // Should still be in edit mode + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + test('disables inputs and buttons while saving', async () => { + const user = userEvent.setup(); + let resolveSave: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + mockOnSave.mockReturnValue(savePromise); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, 'New Name'); + + const buttons = screen.getAllByRole('button'); + const saveButton = buttons[0]; + await user.click(saveButton); + + // Check that input and buttons are disabled + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeDisabled(); + buttons.forEach(button => { + expect(button).toBeDisabled(); + }); + }); + + // Resolve the promise + resolveSave!(); + }); + }); + + describe('Cancel Functionality', () => { + test('exits edit mode when cancel button is clicked', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + + const buttons = screen.getAllByRole('button'); + const cancelButton = buttons[1]; // cross button + await user.click(cancelButton); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByText('Test Name')).toBeInTheDocument(); + }); + + test('exits edit mode when Escape key is pressed', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Test Name'); + await user.type(input, '{Escape}'); + + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(screen.getByText('Test Name')).toBeInTheDocument(); + }); + + test('does not call onSave when cancelled', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, 'New Name'); + + const buttons = screen.getAllByRole('button'); + const cancelButton = buttons[1]; + await user.click(cancelButton); + + expect(mockOnSave).not.toHaveBeenCalled(); + }); + + test('resets input value when cancelled', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + // Enter edit mode and change value + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Original Name'); + await user.clear(input); + await user.type(input, 'Changed Name'); + + // Cancel + const buttons = screen.getAllByRole('button'); + const cancelButton = buttons[1]; + await user.click(cancelButton); + + // Re-enter edit mode + await user.click(screen.getByRole('button')); + + // Should show original name, not the changed one + expect(screen.getByDisplayValue('Original Name')).toBeInTheDocument(); + }); + }); + + describe('Event Propagation', () => { + test('prevents Escape key event from propagating to parent', async () => { + const user = userEvent.setup(); + const parentKeyDownHandler = jest.fn(); + + render( + +
+ +
+
+ ); + + // Enter edit mode + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Test Name'); + + // Press Escape + await user.type(input, '{Escape}'); + + // Check that Escape key press was not propagated to parent + const escapeKeyCalls = parentKeyDownHandler.mock.calls.filter(call => call[0].key === 'Escape'); + expect(escapeKeyCalls).toHaveLength(0); + + // Edit mode should be exited + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + test('prevents Enter key event from propagating to parent', async () => { + const user = userEvent.setup(); + const parentKeyDownHandler = jest.fn(); + mockOnSave.mockResolvedValue(undefined); + + render( + +
+ +
+
+ ); + + // Enter edit mode + await user.click(screen.getByRole('button')); + const input = screen.getByDisplayValue('Old Name'); + await user.clear(input); + await user.type(input, 'New Name{Enter}'); + + // Check that Enter key press was not propagated to parent + const enterKeyCalls = parentKeyDownHandler.mock.calls.filter(call => call[0].key === 'Enter'); + expect(enterKeyCalls).toHaveLength(0); + + // Save should be called + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledWith('New Name'); + }); + }); + }); +}); diff --git a/chaoscenter/web/src/components/EditableStepName/index.ts b/chaoscenter/web/src/components/EditableStepName/index.ts new file mode 100644 index 00000000000..7659281e489 --- /dev/null +++ b/chaoscenter/web/src/components/EditableStepName/index.ts @@ -0,0 +1,3 @@ +import EditableStepName from './EditableStepName'; + +export default EditableStepName; diff --git a/chaoscenter/web/src/models/experiment.ts b/chaoscenter/web/src/models/experiment.ts index ec60461b99e..62a7cdaa8d2 100644 --- a/chaoscenter/web/src/models/experiment.ts +++ b/chaoscenter/web/src/models/experiment.ts @@ -9,6 +9,7 @@ export type ExperimentManifest = KubernetesExperimentManifest; export interface FaultData { faultName: string; + stepName?: string; probes?: ProbeObj[] | ProbeAttributes[]; faultCR?: ChaosExperiment; engineCR?: ChaosEngine; diff --git a/chaoscenter/web/src/services/experiment/ExperimentYamlService.ts b/chaoscenter/web/src/services/experiment/ExperimentYamlService.ts index c9b6f5a2693..edf4a4946c2 100644 --- a/chaoscenter/web/src/services/experiment/ExperimentYamlService.ts +++ b/chaoscenter/web/src/services/experiment/ExperimentYamlService.ts @@ -187,6 +187,12 @@ export abstract class ExperimentYamlService extends ChaosIDB { weight: number ): Promise; + abstract updateFaultStepName( + key: ChaosObjectStoresPrimaryKeys['experiments'], + faultName: string, + stepName: string + ): Promise; + abstract updateExperimentManifestWithFaultData( key: ChaosObjectStoresPrimaryKeys['experiments'], faultData: FaultData diff --git a/chaoscenter/web/src/services/experiment/KubernetesYamlService.ts b/chaoscenter/web/src/services/experiment/KubernetesYamlService.ts index 984bda639f7..3d521da6a6a 100644 --- a/chaoscenter/web/src/services/experiment/KubernetesYamlService.ts +++ b/chaoscenter/web/src/services/experiment/KubernetesYamlService.ts @@ -286,6 +286,33 @@ export class KubernetesYamlService extends ExperimentYamlService { } } + async updateFaultStepName( + key: ChaosObjectStoresPrimaryKeys['experiments'], + faultName: string, + stepName: string + ): Promise { + try { + const tx = (await this.db).transaction(ChaosObjectStoreNameMap.EXPERIMENTS, 'readwrite'); + const store = tx.objectStore(ChaosObjectStoreNameMap.EXPERIMENTS); + const experiment = await store.get(key); + if (!experiment) return; + + experiment.unsavedChanges = true; + const [, steps] = this.getTemplatesAndSteps(experiment?.manifest as KubernetesExperimentManifest); + + steps?.forEach(step => { + step.forEach(s => { + if (s.template === faultName) s.name = stepName; + }); + }); + + await store.put({ ...experiment }, key); + await tx.done; + } catch (_) { + this.handleIDBFailure(); + } + } + async updateNodeSelector( key: ChaosObjectStoresPrimaryKeys['experiments'], nodeSelector: { @@ -781,7 +808,16 @@ export class KubernetesYamlService extends ExperimentYamlService { faultName }; - const [templates] = this.getTemplatesAndSteps(manifest); + const [templates, steps] = this.getTemplatesAndSteps(manifest); + + // Get step name from workflow steps + steps?.forEach(step => { + step.forEach(s => { + if (s.template === faultName) { + faultData.stepName = s.name; + } + }); + }); // Get install-chaos-faults template artifacts const installTemplateArtifacts = templates?.filter(template => template.name === 'install-chaos-faults')[0].inputs diff --git a/chaoscenter/web/src/views/ExperimentCreationFaultConfiguration/ExperimentCreationFaultConfiguration.tsx b/chaoscenter/web/src/views/ExperimentCreationFaultConfiguration/ExperimentCreationFaultConfiguration.tsx index c6584f912e0..b3de1ede231 100644 --- a/chaoscenter/web/src/views/ExperimentCreationFaultConfiguration/ExperimentCreationFaultConfiguration.tsx +++ b/chaoscenter/web/src/views/ExperimentCreationFaultConfiguration/ExperimentCreationFaultConfiguration.tsx @@ -19,6 +19,7 @@ import type { FormikProps } from 'formik'; import { cloneDeep, isEmpty, isEqual } from 'lodash-es'; import { DrawerTypes } from '@components/Drawer/Drawer'; import Drawer from '@components/Drawer'; +import EditableStepName from '@components/EditableStepName'; import { useStrings } from '@strings'; import TargetApplicationTabController from '@controllers/TargetApplicationTab'; import type { FaultData } from '@models'; @@ -43,6 +44,7 @@ interface ExperimentCreationTuneFaultProps { environmentID: string | undefined; faultTuneOperation: GetFaultTunablesOperation; initialServiceIdentifiers: ServiceIdentifiers | undefined; + onStepNameUpdate?: () => void; } enum TuneFaultTab { @@ -58,9 +60,10 @@ export default function ExperimentCreationTuneFaultView({ initialFaultData, infraID, // environmentID, - faultTuneOperation -}: // initialServiceIdentifiers -ExperimentCreationTuneFaultProps): React.ReactElement { + faultTuneOperation, + // initialServiceIdentifiers + onStepNameUpdate +}: ExperimentCreationTuneFaultProps): React.ReactElement { const { getString } = useStrings(); const searchParams = useSearchParams(); const { showError } = useToaster(); @@ -142,11 +145,37 @@ ExperimentCreationTuneFaultProps): React.ReactElement { onClose(); }; + const handleStepNameSave = async (newStepName: string): Promise => { + if (faultData) { + await experimentHandler?.updateFaultStepName(experimentKey, faultData.faultName, newStepName); + setFaultData(prev => (prev ? { ...prev, stepName: newStepName } : prev)); + // Refresh visual builder with new step name + onStepNameUpdate?.(); + } + }; + + const hasUnsavedChanges = (): boolean => { + // Don't compare stepName since it's saved automatically + const initialDataWithoutStepName = initialFaultData ? { ...initialFaultData, stepName: undefined } : undefined; + const currentDataWithoutStepName = faultData ? { ...faultData, stepName: undefined } : undefined; + + return ( + !isEqual(initialDataWithoutStepName, currentDataWithoutStepName) || + faultWeight !== faultData?.weight || + (tuneExperimentRef.current?.dirty ?? false) + ); + }; + const header = ( - {faultData?.faultName} +