diff --git a/.vscode/settings.json b/.vscode/settings.json index 869fb8cd5f..d53354e3d9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,4 +7,5 @@ "**/node_modules": true }, "typescript.tsdk": "node_modules/typescript/lib", + "js/ts.tsdk.path": "node_modules/typescript/lib", } diff --git a/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx b/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx index 226716012e..34aaff61eb 100644 --- a/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx +++ b/packages/dashboard-frontend/src/containers/WorkspacesList/index.tsx @@ -18,7 +18,11 @@ import Fallback from '@/components/Fallback'; import WorkspacesList from '@/pages/WorkspacesList'; import { RootState } from '@/store'; import { fetchBackupConfig, fetchWorkspaceBackupStatus } from '@/store/Backups/actions'; -import { selectAllBackupsByWorkspace, selectBackupConfig } from '@/store/Backups/selectors'; +import { + selectAllBackupsByWorkspace, + selectBackupConfig, + selectBackupSchedule, +} from '@/store/Backups/selectors'; import { selectBranding } from '@/store/Branding/selectors'; import { selectCmEditors } from '@/store/Plugins/devWorkspacePlugins/selectors'; import { workspacesActionCreators } from '@/store/Workspaces'; @@ -56,7 +60,7 @@ export class WorkspacesListContainer extends React.PureComponent { } private fetchBackupStatuses() { - const { allWorkspaces, backupConfig, backupsByWorkspace } = this.props; + const { allWorkspaces, backupConfig, backupsByWorkspace: backupsByWorkspace } = this.props; if (!backupConfig?.registry) { return; } @@ -73,10 +77,11 @@ export class WorkspacesListContainer extends React.PureComponent { render() { const { - backupConfig, - branding, allWorkspaces, + backupConfig, backupsByWorkspace, + backupSchedule, + branding, editors, isLoading, location, @@ -90,12 +95,13 @@ export class WorkspacesListContainer extends React.PureComponent { return ( ); } @@ -110,10 +116,11 @@ function ContainerWrapper(props: MappedProps) { const mapStateToProps = (state: RootState) => { return { - backupConfig: selectBackupConfig(state), - branding: selectBranding(state), allWorkspaces: selectAllWorkspaces(state), + backupConfig: selectBackupConfig(state), backupsByWorkspace: selectAllBackupsByWorkspace(state), + backupSchedule: selectBackupSchedule(state), + branding: selectBranding(state), editors: selectCmEditors(state), isLoading: selectIsLoading(state), }; diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx index 57241bb2d9..b674e9140a 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/DefaultRegistryForm/index.tsx @@ -10,15 +10,14 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by Claude Sonnet 4.6 import { BackupItem } from '@eclipse-che/common'; -import { Form, ValidatedOptions } from '@patternfly/react-core'; -import React, { useEffect, useState } from 'react'; +import { Form } from '@patternfly/react-core'; +import React, { useCallback, useEffect, useState } from 'react'; import { BackupSelectorField } from '@/pages/RestoreFromBackup/DefaultRegistryForm/BackupSelectorField'; import { ImagePreviewField } from '@/pages/RestoreFromBackup/DefaultRegistryForm/ImagePreviewField'; -import { useRestoreFormValidation } from '@/pages/RestoreFromBackup/useRestoreFormValidation'; import { WorkspaceNameField } from '@/pages/RestoreFromBackup/WorkspaceNameField'; export type DefaultRegistryRestoreData = { @@ -45,31 +44,10 @@ export const DefaultRegistryRestoreForm: React.FC = ({ }) => { const [selectedBackup, setSelectedBackup] = useState(undefined); - const { - workspaceName, - workspaceNameValidated, - workspaceNameError, - workspaceNameWarning, - setWorkspaceName, - handleWorkspaceNameChange, - } = useRestoreFormValidation(existingWorkspaceNames, existingBackupNames); + const [workspaceName, setWorkspaceName] = useState(''); + const [isWorkspaceNameValid, setIsWorkspaceNameValid] = useState(false); - // Determine validity and notify parent - useEffect(() => { - const isValid = - workspaceNameValidated === ValidatedOptions.success || - workspaceNameValidated === ValidatedOptions.warning; - const imageUrl = selectedBackup ? selectedBackup.imageUrl : ''; - - if (isValid) { - onValidationChange(true, { workspaceName, imageUrl }); - } else { - onValidationChange(false, null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedBackup, workspaceName, workspaceNameValidated]); - - // Pre-select backup and populate workspace name from initialImageUrl + // Pre-select backup from initialImageUrl useEffect(() => { if (!initialImageUrl || selectedBackup) { return; @@ -77,25 +55,30 @@ export const DefaultRegistryRestoreForm: React.FC = ({ const match = backups.find(b => b.imageUrl === initialImageUrl); if (match) { setSelectedBackup(match); - setWorkspaceName(match.workspaceName); } - }, [backups, initialImageUrl, selectedBackup, setWorkspaceName]); + }, [backups, initialImageUrl, selectedBackup]); + + // Notify parent whenever form validity changes + useEffect(() => { + const imageUrl = selectedBackup ? selectedBackup.imageUrl : ''; + if (isWorkspaceNameValid) { + onValidationChange(true, { workspaceName, imageUrl }); + } else { + onValidationChange(false, null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedBackup, workspaceName, isWorkspaceNameValid]); const handleBackupChange = (backup: BackupItem | undefined) => { setSelectedBackup(backup); - if (backup) { - setWorkspaceName(backup.workspaceName); - } }; - // Re-validate when existingWorkspaceNames changes - // (e.g. a workspace is created or deleted while the form is open) - useEffect(() => { - if (workspaceName) { - setWorkspaceName(workspaceName); - } - }, [existingWorkspaceNames, workspaceName, setWorkspaceName]); + const handleWorkspaceNameChange = useCallback((name: string, isValid: boolean) => { + setWorkspaceName(name); + setIsWorkspaceNameValid(isValid); + }, []); + const suggestedWorkspaceName = selectedBackup?.workspaceName ?? ''; const imageUrl = selectedBackup ? selectedBackup.imageUrl : ''; return ( @@ -109,11 +92,10 @@ export const DefaultRegistryRestoreForm: React.FC = ({ handleWorkspaceNameChange(value)} + value={suggestedWorkspaceName} + existingWorkspaceNames={existingWorkspaceNames} + existingBackupNames={existingBackupNames} + onChange={handleWorkspaceNameChange} actionButton={actionButton} /> diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__tests__/index.spec.tsx index fde814cbc0..4d25ec02eb 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/__tests__/index.spec.tsx @@ -109,6 +109,18 @@ describe('ExternalRegistryRestoreForm', () => { expect(mockOnValidationChange).toHaveBeenCalledWith(false, null); }); + + test('should NOT reset workspace name when backup image URL changes after manual edit', async () => { + renderComponent(); + + const nameInput = screen.getByLabelText('Workspace name'); + await userEvent.type(nameInput, 'my-custom-name'); + + const urlInput = screen.getByLabelText('Backup image URL'); + await userEvent.type(urlInput, 'registry.example.com/ns/workspace:latest'); + + expect(nameInput).toHaveValue('my-custom-name'); + }); }); type ComponentOptions = { diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/index.tsx index 9a7d79a86c..e5e4253561 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/ExternalRegistryForm/index.tsx @@ -14,16 +14,10 @@ import { BACKUP_IMAGE_URL_PATTERN } from '@eclipse-che/common'; import { Form, ValidatedOptions } from '@patternfly/react-core'; -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { ImageUrlField } from '@/pages/RestoreFromBackup/ExternalRegistryForm/ImageUrlField'; -import { - sanitizeImageUrl, - sanitizeWorkspaceName, - validateWorkspaceName, - ValidationState, -} from '@/pages/RestoreFromBackup/helpers'; -import { useRestoreFormValidation } from '@/pages/RestoreFromBackup/useRestoreFormValidation'; +import { sanitizeImageUrl, ValidationState } from '@/pages/RestoreFromBackup/helpers'; import { WorkspaceNameField } from '@/pages/RestoreFromBackup/WorkspaceNameField'; export type ExternalRegistryRestoreData = { @@ -82,58 +76,29 @@ export const ExternalRegistryRestoreForm: React.FC = ({ const initialUrl = initialImageUrl || ''; const [imageUrl, setImageUrl] = useState(initialUrl); - const initialWorkspaceName = detectWorkspaceName(initialUrl); - const { - workspaceName, - workspaceNameValidated, - workspaceNameError, - workspaceNameWarning, - setWorkspaceName, - handleWorkspaceNameChange, - } = useRestoreFormValidation(existingWorkspaceNames, existingBackupNames); - - // Initialize workspace name from initialImageUrl on first render - React.useEffect(() => { - if (initialWorkspaceName) { - setWorkspaceName(initialWorkspaceName); - } - const { validationState } = computeImageUrlValidation(initialUrl); - const wsIsValid = initialWorkspaceName.length > 0; - const isValid = validationState === 'valid' && wsIsValid; - onValidationChange( - isValid, - isValid ? { workspaceName: initialWorkspaceName, imageUrl: initialUrl } : null, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const [workspaceName, setWorkspaceName] = useState(''); + const [isWorkspaceNameValid, setIsWorkspaceNameValid] = useState(false); - // Compute URL validation state during render (derived from imageUrl) const { validationState, imageUrlValidated, imageUrlError } = computeImageUrlValidation(imageUrl); + const detectedWorkspaceName = validationState === 'valid' ? detectWorkspaceName(imageUrl) : ''; + + // Notify parent whenever form validity changes + useEffect(() => { + const isValid = validationState === 'valid' && isWorkspaceNameValid; + onValidationChange(isValid, isValid ? { workspaceName, imageUrl } : null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [imageUrl, workspaceName, isWorkspaceNameValid, validationState]); + const handleImageUrlChange = (value: string) => { const sanitized = sanitizeImageUrl(value); setImageUrl(sanitized); - - const { validationState: newValidationState } = computeImageUrlValidation(sanitized); - const newWorkspaceName = newValidationState === 'valid' ? detectWorkspaceName(sanitized) : ''; - setWorkspaceName(newWorkspaceName); - - const isValid = newValidationState === 'valid' && newWorkspaceName.length > 0; - onValidationChange( - isValid, - isValid ? { workspaceName: newWorkspaceName, imageUrl: sanitized } : null, - ); }; - const wrappedHandleWorkspaceNameChange = (value: string) => { - handleWorkspaceNameChange(value); - const sanitized = sanitizeWorkspaceName(value); - const { validated } = validateWorkspaceName(sanitized); - const wsIsValid = - validated === ValidatedOptions.success || validated === ValidatedOptions.warning; - const isValid = validationState === 'valid' && wsIsValid; - onValidationChange(isValid, isValid ? { workspaceName: sanitized, imageUrl } : null); - }; + const handleWorkspaceNameChange = useCallback((name: string, isValid: boolean) => { + setWorkspaceName(name); + setIsWorkspaceNameValid(isValid); + }, []); return (
e.preventDefault()}> @@ -147,11 +112,10 @@ export const ExternalRegistryRestoreForm: React.FC = ({ diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__mocks__/index.tsx deleted file mode 100644 index 214acecfbd..0000000000 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__mocks__/index.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2018-2025 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -// Generated by Claude Opus 4.6 - -import React from 'react'; - -import { Props } from '@/pages/RestoreFromBackup/WorkspaceNameField'; - -export class WorkspaceNameField extends React.PureComponent { - public render(): React.ReactNode { - const { value, validated, error, warning, actionButton } = this.props; - - return ( -
- {value} - {validated} - {error} - {warning ?? ''} - {actionButton} -
- ); - } -} diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx index 256c601678..f8b8b57675 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/__tests__/index.spec.tsx @@ -10,9 +10,8 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by Claude Sonnet 4.6 -import { ValidatedOptions } from '@patternfly/react-core'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -41,25 +40,6 @@ describe('WorkspaceNameField', () => { expect(screen.getByText('Enter a workspace name.')).toBeInTheDocument(); }); - test('should show warning message instead of helperText when validated is warning', () => { - renderComponent({ - validated: ValidatedOptions.warning, - warning: 'Workspace "my-ws" still exists on this cluster.', - }); - - expect(screen.getByText('Workspace "my-ws" still exists on this cluster.')).toBeInTheDocument(); - expect(screen.queryByText('Enter a workspace name.')).not.toBeInTheDocument(); - }); - - test('should show error message when validated is error', () => { - renderComponent({ - validated: ValidatedOptions.error, - error: 'A workspace with this name already exists.', - }); - - expect(screen.getByText('A workspace with this name already exists.')).toBeInTheDocument(); - }); - test('should call onChange on input change', async () => { renderComponent(); @@ -92,13 +72,98 @@ describe('WorkspaceNameField', () => { expect(screen.getByText('Workspace name is required.')).toBeInTheDocument(); }); + + test('should show error when workspace name conflicts with existing workspace', async () => { + renderComponent({ + existingWorkspaceNames: new Set(['my-workspace']), + }); + + const input = screen.getByLabelText('Workspace name'); + await userEvent.type(input, 'my-workspace'); + + expect(screen.getByText('A workspace with this name already exists.')).toBeInTheDocument(); + }); + + test('should show warning when workspace name conflicts with existing backup', async () => { + renderComponent({ + existingBackupNames: new Set(['old-ws']), + }); + + const input = screen.getByLabelText('Workspace name'); + await userEvent.type(input, 'old-ws'); + + expect( + screen.getByText( + 'A backup named "old-ws" already exists. The new workspace will be associated with that backup.', + ), + ).toBeInTheDocument(); + }); + + test('should follow parent value changes when untouched', () => { + const { reRenderComponent } = renderComponent({ value: '' }); + + reRenderComponent({ value: 'auto-detected' }); + + const input = screen.getByLabelText('Workspace name'); + expect(input).toHaveValue('auto-detected'); + }); + + test('should ignore parent value changes after user has typed', async () => { + const { reRenderComponent } = renderComponent({ value: '' }); + + const input = screen.getByLabelText('Workspace name'); + await userEvent.type(input, 'my-custom-name'); + + reRenderComponent({ value: 'auto-detected' }); + + expect(input).toHaveValue('my-custom-name'); + }); + + test('should call onChange with isValid=true for valid workspace name', async () => { + renderComponent(); + + const input = screen.getByLabelText('Workspace name'); + await userEvent.type(input, 'valid-name'); + + const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1]; + expect(lastCall[0]).toBe('valid-name'); + expect(lastCall[1]).toBe(true); + }); + + test('should show error when name matches active workspace even if backup exists', async () => { + renderComponent({ + existingWorkspaceNames: new Set(['old-ws']), + existingBackupNames: new Set(['old-ws']), + }); + + const input = screen.getByLabelText('Workspace name'); + await userEvent.type(input, 'old-ws'); + + expect(screen.getByText('A workspace with this name already exists.')).toBeInTheDocument(); + expect( + screen.queryByText(/already exists. The new workspace will be associated/), + ).not.toBeInTheDocument(); + }); + + test('should call onChange with isValid=false for conflicting workspace name', async () => { + renderComponent({ + existingWorkspaceNames: new Set(['taken-name']), + }); + + const input = screen.getByLabelText('Workspace name'); + await userEvent.type(input, 'taken-name'); + + const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1]; + expect(lastCall[0]).toBe('taken-name'); + expect(lastCall[1]).toBe(false); + }); }); function getComponent( options: { - validated?: ValidatedOptions; - error?: string; - warning?: string; + value?: string; + existingWorkspaceNames?: Set; + existingBackupNames?: Set; actionButton?: React.ReactNode; } = {}, ) { @@ -106,10 +171,9 @@ function getComponent( Restore} /> diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx index 309728f316..2c9c182c44 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/WorkspaceNameField/index.tsx @@ -10,7 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ -// Generated by Claude Opus 4.6 +// Generated by Claude Sonnet 4.6 import { FormGroup, @@ -22,16 +22,20 @@ import { ValidatedOptions, } from '@patternfly/react-core'; import { ExclamationCircleIcon, ExclamationTriangleIcon } from '@patternfly/react-icons'; -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; + +import { + sanitizeWorkspaceName, + validateWorkspaceNameWithConflicts, +} from '@/pages/RestoreFromBackup/helpers'; export type Props = { fieldId: string; helperText: string; value: string; - validated: ValidatedOptions; - error: string; - warning?: string; - onChange: (value: string) => void; + existingWorkspaceNames: Set; + existingBackupNames?: Set; + onChange: (value: string, isValid: boolean) => void; actionButton: React.ReactNode; }; @@ -39,15 +43,52 @@ export const WorkspaceNameField: React.FC = ({ fieldId, helperText, value, - validated, - error, - warning, + existingWorkspaceNames, + existingBackupNames, onChange, actionButton, }) => { + const [internalValue, setInternalValue] = useState(value); const [dirty, setDirty] = useState(false); + const [validated, setValidated] = useState(ValidatedOptions.default); + const [error, setError] = useState(''); + const [warning, setWarning] = useState(''); + + const applyValidation = useCallback( + (name: string) => { + const result = validateWorkspaceNameWithConflicts( + name, + existingWorkspaceNames, + existingBackupNames, + ); + setValidated(result.validated); + setError(result.error); + setWarning(result.warning); + return result; + }, + [existingWorkspaceNames, existingBackupNames], + ); + + // Accept parent's suggestion when untouched (GitProviderEndpoint pattern) + useEffect(() => { + if (!dirty) { + setInternalValue(value); + const result = applyValidation(value); + onChange(value, result.isValid); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, applyValidation]); + + // Re-validate when conflict sets change (e.g. workspace created/deleted while form is open) + useEffect(() => { + if (internalValue) { + const result = applyValidation(internalValue); + onChange(internalValue, result.isValid); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existingWorkspaceNames, existingBackupNames]); - const isDirtyEmpty = dirty && !value; + const isDirtyEmpty = dirty && !internalValue; const effectiveValidated = isDirtyEmpty ? ValidatedOptions.error : validated; const helperTextInvalid = error || warning || ''; @@ -84,9 +125,12 @@ export const WorkspaceNameField: React.FC = ({ return {effectiveHelperText}; }; - const handleChange = (_event: React.FormEvent, _value: string) => { + const handleChange = (_event: React.FormEvent, newValue: string) => { setDirty(true); - onChange(_value); + const sanitized = sanitizeWorkspaceName(newValue); + setInternalValue(sanitized); + const result = applyValidation(sanitized); + onChange(sanitized, result.isValid); }; return ( @@ -96,7 +140,7 @@ export const WorkspaceNameField: React.FC = ({ id={fieldId} aria-label="Workspace name" placeholder="my-workspace" - value={value} + value={internalValue} validated={ effectiveValidated === ValidatedOptions.error ? 'error' diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/__tests__/useRestoreFormValidation.spec.ts b/packages/dashboard-frontend/src/pages/RestoreFromBackup/__tests__/useRestoreFormValidation.spec.ts deleted file mode 100644 index 4a620f9fde..0000000000 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/__tests__/useRestoreFormValidation.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright (c) 2018-2025 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -// Generated by Claude Opus 4.6 - -import { ValidatedOptions } from '@patternfly/react-core'; -import { act, renderHook } from '@testing-library/react'; - -import { useRestoreFormValidation } from '@/pages/RestoreFromBackup/useRestoreFormValidation'; - -describe('useRestoreFormValidation', () => { - test('should have initial state as default/empty', () => { - const { result } = renderHook(() => useRestoreFormValidation()); - - expect(result.current.workspaceName).toBe(''); - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.default); - expect(result.current.workspaceNameError).toBe(''); - expect(result.current.workspaceNameWarning).toBe(''); - }); - - test('should return success state for valid name', () => { - const { result } = renderHook(() => useRestoreFormValidation()); - - act(() => { - result.current.setWorkspaceName('valid-name'); - }); - - expect(result.current.workspaceName).toBe('valid-name'); - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.success); - expect(result.current.workspaceNameError).toBe(''); - }); - - test('should return error for name longer than 63 characters', () => { - const { result } = renderHook(() => useRestoreFormValidation()); - - const longName = 'a'.repeat(64); - act(() => { - result.current.setWorkspaceName(longName); - }); - - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.error); - expect(result.current.workspaceNameError).toBe('Workspace name must be 63 characters or less.'); - }); - - test('should return error for invalid characters', () => { - const { result } = renderHook(() => useRestoreFormValidation()); - - act(() => { - result.current.setWorkspaceName('-invalid'); - }); - - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.error); - expect(result.current.workspaceNameError).toContain( - 'Must contain only lowercase letters, numbers, and hyphens', - ); - }); - - test('should return error when name matches existing workspace', () => { - const existingNames = new Set(['my-workspace']); - const { result } = renderHook(() => useRestoreFormValidation(existingNames)); - - act(() => { - result.current.setWorkspaceName('my-workspace'); - }); - - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.error); - expect(result.current.workspaceNameError).toBe('A workspace with this name already exists.'); - expect(result.current.workspaceNameWarning).toBe(''); - }); - - test('should sanitize input via handleWorkspaceNameChange', () => { - const { result } = renderHook(() => useRestoreFormValidation()); - - act(() => { - result.current.handleWorkspaceNameChange('My_Workspace'); - }); - - expect(result.current.workspaceName).toBe('myworkspace'); - }); - - test('should show warning when workspace name matches an existing backup', () => { - const existingWorkspaceNames = new Set(); - const existingBackupNames = new Set(['old-ws']); - const { result } = renderHook(() => - useRestoreFormValidation(existingWorkspaceNames, existingBackupNames), - ); - - act(() => { - result.current.setWorkspaceName('old-ws'); - }); - - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.warning); - expect(result.current.workspaceNameWarning).toBe( - 'A backup named "old-ws" already exists. The new workspace will be associated with that backup.', - ); - expect(result.current.workspaceNameError).toBe(''); - }); - - test('should show error when name matches active workspace even if backup exists', () => { - const existingWorkspaceNames = new Set(['old-ws']); - const existingBackupNames = new Set(['old-ws']); - const { result } = renderHook(() => - useRestoreFormValidation(existingWorkspaceNames, existingBackupNames), - ); - - act(() => { - result.current.setWorkspaceName('old-ws'); - }); - - // Active workspace conflict takes precedence over backup conflict - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.error); - expect(result.current.workspaceNameError).toBe('A workspace with this name already exists.'); - }); - - test('should return default state for empty name', () => { - const { result } = renderHook(() => useRestoreFormValidation()); - - act(() => { - result.current.setWorkspaceName('valid-name'); - }); - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.success); - - act(() => { - result.current.setWorkspaceName(''); - }); - expect(result.current.workspaceNameValidated).toBe(ValidatedOptions.default); - expect(result.current.workspaceNameError).toBe(''); - }); -}); diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/helpers.ts b/packages/dashboard-frontend/src/pages/RestoreFromBackup/helpers.ts index e67b24c53e..660d5c2376 100644 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/helpers.ts +++ b/packages/dashboard-frontend/src/pages/RestoreFromBackup/helpers.ts @@ -50,3 +50,41 @@ export function validateWorkspaceName(name: string): { } return { validated: ValidatedOptions.success, error: '' }; } + +export function validateWorkspaceNameWithConflicts( + name: string, + existingWorkspaceNames: Set, + existingBackupNames?: Set, +): { + validated: ValidatedOptions; + error: string; + warning: string; + isValid: boolean; +} { + const { validated, error } = validateWorkspaceName(name); + + if (validated === ValidatedOptions.success && existingWorkspaceNames.has(name)) { + return { + validated: ValidatedOptions.error, + error: 'A workspace with this name already exists.', + warning: '', + isValid: false, + }; + } + + if (validated === ValidatedOptions.success && existingBackupNames?.has(name)) { + return { + validated: ValidatedOptions.warning, + error: '', + warning: `A backup named "${name}" already exists. The new workspace will be associated with that backup.`, + isValid: true, + }; + } + + return { + validated, + error, + warning: '', + isValid: validated === ValidatedOptions.success, + }; +} diff --git a/packages/dashboard-frontend/src/pages/RestoreFromBackup/useRestoreFormValidation.ts b/packages/dashboard-frontend/src/pages/RestoreFromBackup/useRestoreFormValidation.ts deleted file mode 100644 index 84a89f17f6..0000000000 --- a/packages/dashboard-frontend/src/pages/RestoreFromBackup/useRestoreFormValidation.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2018-2025 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ - -// Generated by Claude Opus 4.6 - -import { ValidatedOptions } from '@patternfly/react-core'; -import { useCallback, useState } from 'react'; - -import { sanitizeWorkspaceName, validateWorkspaceName } from '@/pages/RestoreFromBackup/helpers'; - -export type WorkspaceNameValidation = { - workspaceName: string; - workspaceNameValidated: ValidatedOptions; - workspaceNameError: string; - workspaceNameWarning: string; - setWorkspaceName: (name: string) => void; - handleWorkspaceNameChange: (value: string) => void; -}; - -export function useRestoreFormValidation( - existingWorkspaceNames?: Set, - existingBackupNames?: Set, -): WorkspaceNameValidation { - const [workspaceName, setWorkspaceNameState] = useState(''); - const [workspaceNameValidated, setWorkspaceNameValidated] = useState( - ValidatedOptions.default, - ); - const [workspaceNameError, setWorkspaceNameError] = useState(''); - const [workspaceNameWarning, setWorkspaceNameWarning] = useState(''); - - const setWorkspaceName = useCallback( - (name: string) => { - const { validated, error } = validateWorkspaceName(name); - - // Check for name conflict if existingWorkspaceNames is provided - if (validated === ValidatedOptions.success && existingWorkspaceNames?.has(name)) { - setWorkspaceNameState(name); - setWorkspaceNameValidated(ValidatedOptions.error); - setWorkspaceNameError('A workspace with this name already exists.'); - setWorkspaceNameWarning(''); - return; - } - - // Check for backup name conflict (warning, not error — user can proceed) - if (validated === ValidatedOptions.success && existingBackupNames?.has(name)) { - setWorkspaceNameState(name); - setWorkspaceNameValidated(ValidatedOptions.warning); - setWorkspaceNameError(''); - setWorkspaceNameWarning( - `A backup named "${name}" already exists. The new workspace will be associated with that backup.`, - ); - return; - } - - setWorkspaceNameState(name); - setWorkspaceNameValidated(validated); - setWorkspaceNameError(error); - setWorkspaceNameWarning(''); - }, - [existingWorkspaceNames, existingBackupNames], - ); - - const handleWorkspaceNameChange = useCallback( - (value: string) => { - const sanitized = sanitizeWorkspaceName(value); - setWorkspaceName(sanitized); - }, - [setWorkspaceName], - ); - - return { - workspaceName, - workspaceNameValidated, - workspaceNameError, - workspaceNameWarning, - setWorkspaceName, - handleWorkspaceNameChange, - }; -} diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTable/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTable/index.tsx index 9d6a09474e..2a11676a6b 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTable/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTable/index.tsx @@ -50,6 +50,7 @@ export class BackupsTable extends React.PureComponent { + {rows.map((row, rowIndex) => ( +
{columns.map((col, colIndex) => ( {
{}, + isSelected: false, + isDisabled: true, + }} + /> {row.cells.map((cell, cellIndex) => ( {cell.title} diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/__tests__/index.spec.tsx index 2c3d61478d..9de1ea5c51 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/__tests__/index.spec.tsx @@ -23,11 +23,6 @@ jest.mock('@/services/helpers/dates', () => ({ formatRelativeDate: jest.fn(() => '2 hours ago'), })); -jest.mock('cronstrue', () => ({ - __esModule: true, - default: { toString: jest.fn(() => 'At 01:00 AM') }, -})); - jest.mock('@/components/BackupStatusBadge', () => ({ BackupStatusBadge: (props: { status: string; @@ -73,15 +68,10 @@ const mockBackups: BackupItem[] = [ }, ]; -function renderComponent(backups: BackupItem[] = mockBackups, backupSchedule?: string) { +function renderComponent(backups: BackupItem[] = mockBackups) { return render( - + , ); } @@ -429,27 +419,4 @@ describe('BackupsTableView', () => { expect(size).toHaveTextContent('1.0 MB'); }); }); - - describe('toolbar', () => { - test('should render toolbar', () => { - renderComponent(); - - expect(screen.getByTestId('backups-view-toolbar')).toBeTruthy(); - }); - - test('should show "No backup schedule configured" when backupSchedule is undefined', () => { - renderComponent(mockBackups, undefined); - - expect(screen.getByTestId('next-scheduled-backup')).toHaveTextContent( - 'No backup schedule configured', - ); - }); - - test('should display human-readable backup schedule', () => { - renderComponent(mockBackups, '0 1 * * *'); - - const el = screen.getByTestId('next-scheduled-backup'); - expect(el).toHaveTextContent('Backup schedule: At 01:00 AM'); - }); - }); }); diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx index d9d451b34b..25a1ee906e 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/BackupsTableView/index.tsx @@ -34,7 +34,6 @@ type Props = { backups: BackupItem[]; namespace: string; navigate: (location: ReturnType) => void; - backupSchedule: string | undefined; }; type State = { @@ -125,7 +124,7 @@ export class BackupsTableView extends React.PureComponent { } public render(): React.ReactElement { - const { backups, namespace, backupSchedule } = this.props; + const { backups, namespace } = this.props; const { sortBy, filterValue, expandedDropdowns } = this.state; const filtered = this.getFilteredBackups(); @@ -141,7 +140,6 @@ export class BackupsTableView extends React.PureComponent { const toolbar = ( this.handleFilterChange(value)} onFilterApply={() => this.handleFilterApply()} onFilterClear={() => this.handleFilterClear()} diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/__tests__/index.spec.tsx new file mode 100644 index 0000000000..db572a24bc --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/__tests__/index.spec.tsx @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { BackupsListToolbar } from '..'; + +const mockOnFilterChange = jest.fn(); +const mockOnFilterApply = jest.fn(); +const mockOnFilterClear = jest.fn(); +const mockOnRestoreClick = jest.fn(); + +describe('Backups List Toolbar', () => { + function renderComponent( + propsOverrides: Partial> = {}, + ) { + const defaultProps: React.ComponentProps = { + filterValue: '', + onFilterChange: mockOnFilterChange, + onFilterApply: mockOnFilterApply, + onFilterClear: mockOnFilterClear, + onRestoreClick: mockOnRestoreClick, + }; + return render(); + } + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly', () => { + renderComponent(); + + expect(screen.queryByRole('searchbox')).toBeTruthy(); + expect(screen.queryByRole('button', { name: /search backups/i })).toBeTruthy(); + expect(screen.queryByRole('button', { name: /restore workspace/i })).toBeTruthy(); + }); + + it('should call onFilterChange when typing in search', async () => { + renderComponent(); + + const searchbox = screen.getByRole('searchbox'); + await userEvent.type(searchbox, 'a'); + + expect(mockOnFilterChange).toHaveBeenCalledWith('a'); + }); + + it('should call onFilterApply when clicking search button', async () => { + renderComponent(); + + const searchButton = screen.getByRole('button', { name: /search backups/i }); + await userEvent.click(searchButton); + + expect(mockOnFilterApply).toHaveBeenCalled(); + }); + + it('should call onFilterApply when pressing Enter', async () => { + renderComponent(); + + const searchbox = screen.getByRole('searchbox'); + await userEvent.type(searchbox, '{Enter}'); + + expect(mockOnFilterApply).toHaveBeenCalled(); + }); + + it('should call onFilterClear when pressing Escape', async () => { + renderComponent(); + + const searchbox = screen.getByRole('searchbox'); + await userEvent.type(searchbox, '{Escape}'); + + expect(mockOnFilterClear).toHaveBeenCalled(); + }); + + it('should call onRestoreClick when clicking Restore Workspace', async () => { + renderComponent(); + + const restoreButton = screen.getByRole('button', { name: /restore workspace/i }); + await userEvent.click(restoreButton); + + expect(mockOnRestoreClick).toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/index.module.css b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/index.module.css new file mode 100644 index 0000000000..acf9015b34 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/index.module.css @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +/* NOTE: this hidden checkbox is needed to alignt the backup toolbar items in same way as the active workspaces toolbar items. */ +.toolbarCheckbox { + margin-top: 6px; + margin-left: 26px; + visibility: hidden; +} diff --git a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/index.tsx b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/index.tsx index e08f7f51a2..5291ed7017 100644 --- a/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspacesList/BackupsView/Toolbar/index.tsx @@ -15,8 +15,7 @@ import { Button, ButtonVariant, - Content, - ContentVariants, + Checkbox, InputGroup, TextInput, Toolbar, @@ -25,12 +24,12 @@ import { ToolbarToggleGroup, } from '@patternfly/react-core'; import { EllipsisVIcon, RedoIcon, SearchIcon } from '@patternfly/react-icons'; -import cronstrue from 'cronstrue'; import React from 'react'; +import styles from '@/pages/WorkspacesList/BackupsView/Toolbar/index.module.css'; + type Props = { filterValue: string; - backupSchedule: string | undefined; onFilterChange: (value: string) => void; onFilterApply: () => void; onFilterClear: () => void; @@ -50,30 +49,31 @@ export class BackupsListToolbar extends React.PureComponent { } public render(): React.ReactElement { - const { filterValue, backupSchedule, onFilterChange, onFilterApply, onRestoreClick } = - this.props; - - let scheduleLabel: string; - if (backupSchedule) { - try { - scheduleLabel = `Backup schedule: ${cronstrue.toString(backupSchedule)}`; - } catch { - scheduleLabel = `Backup schedule: ${backupSchedule}`; - } - } else { - scheduleLabel = 'No backup schedule configured'; - } + const { filterValue, onFilterChange, onFilterApply, onRestoreClick } = this.props; return ( - + + + {/* NOTE: this hidden checkbox is needed to align the backup toolbar items in the same way as the active workspaces toolbar items. */} + + onFilterChange(value)} @@ -82,7 +82,7 @@ export class BackupsListToolbar extends React.PureComponent { />