diff --git a/apps/app-frontend/src/shell/StorageRoutes.test.tsx b/apps/app-frontend/src/shell/StorageRoutes.test.tsx index 86b61ee5..8aa2d014 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.test.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.test.tsx @@ -1,11 +1,22 @@ import { Route, Routes } from 'react-router-dom'; import { screen, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders'; import { StorageRoutes } from './StorageRoutes'; +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // useBlocker requires a data router; this test harness renders under a + // plain MemoryRouter, so StorageBackendCreatePage's LeaveFormConfirmation + // is stubbed out here rather than exercised (see its own test file). + useBlocker: () => ({ state: 'unblocked' as const }), + }; +}); + const renderAt = (path: string) => renderWithProviders( @@ -21,10 +32,11 @@ describe('StorageRoutes', () => { expect(screen.getByRole('button', { name: 'Create backend' })).toBeInTheDocument(); }); - it('renders a placeholder for backends/create', () => { + it('renders the real create form for backends/create', () => { renderAt('/admin/infrastructure/storage/backends/create'); - expect(screen.getByText('Create storage backend')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Create storage backend' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument(); }); it('renders a placeholder for backends/:id/edit', () => { diff --git a/apps/app-frontend/src/shell/StorageRoutes.tsx b/apps/app-frontend/src/shell/StorageRoutes.tsx index b9437858..72825216 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import StorageTierCreatePage from '@osac/ui-components/components/Storage/StorageTierCreatePage'; import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import { StorageBackendCreatePage } from '@osac/ui-components/pages/admin/StorageBackendCreatePage'; import { StorageManagementPage } from '@osac/ui-components/pages/admin/StorageManagementPage'; import { StoragePlaceholder } from '@osac/ui-components/pages/admin/StoragePlaceholder'; @@ -12,10 +13,7 @@ export const StorageRoutes = () => { } /> } /> - } - /> + } /> } diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 8ebfe198..75ece9be 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -12,9 +12,9 @@ "All": "All", "An external IP pool is required": "An external IP pool is required", "API URL": "API URL", - "Are you sure you want to cancel? Your selections and entered data will be lost.": "Are you sure you want to cancel? Your selections and entered data will be lost.", "Are you sure you want to disable Identity provider {{idpName}}": "Are you sure you want to disable Identity provider {{idpName}}", "Are you sure you want to enable Identity provider {{idpName}}": "Are you sure you want to enable Identity provider {{idpName}}", + "Are you sure you want to leave? Your entered data will be lost.": "Are you sure you want to leave? Your entered data will be lost.", "At least one CIDR (IPv4 or IPv6) is required": "At least one CIDR (IPv4 or IPv6) is required", "At least one node set is required": "At least one node set is required", "At least one user is required": "At least one user is required", @@ -94,6 +94,7 @@ "catalogProvision.vm.wizardDescription": "Select a catalog item, configure, and provision.", "catalogProvision.vm.wizardTitle": "Create virtual machine", "catalogProvision.wizard.navAria": "{{title}} steps", + "Ceph": "Ceph", "CIDR": "CIDR", "CIDR must be within parent virtual network range": "CIDR must be within parent virtual network range", "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})": "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})", @@ -167,7 +168,7 @@ "Disable {{idpName}}?": "Disable {{idpName}}?", "Disabled": "Disabled", "Discard and close": "Discard and close", - "Discard wizard progress?": "Discard wizard progress?", + "Discard unsaved changes?": "Discard unsaved changes?", "Domain {{number}}": "Domain {{number}}", "Domain is required": "Domain is required", "Domains": "Domains", @@ -185,6 +186,7 @@ "Enable {{idpName}}?": "Enable {{idpName}}?", "Encryption enabled": "Encryption enabled", "Endpoint": "Endpoint", + "Endpoint is required": "Endpoint is required", "Error": "Error", "Error loading external IP pools": "Error loading external IP pools", "Error loading virtual networks": "Error loading virtual networks", @@ -204,6 +206,7 @@ "Failed to connect to the console.": "Failed to connect to the console.", "Failed to create resource": "Failed to create resource", "Failed to create role binding": "Failed to create role binding", + "Failed to create storage backend": "Failed to create storage backend", "Failed to create storage tier": "Failed to create storage tier", "Failed to create subnet": "Failed to create subnet", "Failed to create tenant": "Failed to create tenant", @@ -348,6 +351,7 @@ "Overview": "Overview", "Parent virtual network": "Parent virtual network", "Password": "Password", + "Password is required": "Password is required", "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.": "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.", "Paste from clipboard": "Paste from clipboard", "Paused": "Paused", @@ -368,6 +372,7 @@ "Protocol is required": "Protocol is required", "Protocol(s)": "Protocol(s)", "Provider": "Provider", + "Provider is required": "Provider is required", "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", "Provision bare metal": "Provision bare metal", "Provisioning": "Provisioning", @@ -376,6 +381,7 @@ "Pull secret": "Pull secret", "Pull secret is required": "Pull secret is required", "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).", + "Pure": "Pure", "Quota (GiB)": "Quota (GiB)", "Reactivate": "Reactivate", "Ready": "Ready", @@ -467,6 +473,7 @@ "Unspecified": "Unspecified", "Update": "Update", "Update role binding": "Update role binding", + "Use credentials": "Use credentials", "Use IPv4 CIDR notation (for example 10.128.0.0/14).": "Use IPv4 CIDR notation (for example 10.128.0.0/14).", "Use IPv4 CIDR notation (for example 172.30.0.0/16).": "Use IPv4 CIDR notation (for example 172.30.0.0/16).", "User data": "User data", @@ -475,7 +482,9 @@ "User data must not exceed 64 KB.": "User data must not exceed 64 KB.", "User info URL": "User info URL", "Username": "Username", + "Username is required": "Username is required", "Users": "Users", + "VAST": "VAST", "View and manage your bare metal instances.": "View and manage your bare metal instances.", "View password": "View password", "Virtual machine conditions": "Virtual machine conditions", diff --git a/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx b/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx index 9ecd7725..45a663ea 100644 --- a/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx +++ b/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx @@ -31,13 +31,13 @@ const LeaveFormConfirmation = () => { return ( - {t('Are you sure you want to cancel? Your selections and entered data will be lost.')} + {t('Are you sure you want to leave? Your entered data will be lost.')} diff --git a/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx b/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx index 0427030a..83a9da00 100644 --- a/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx +++ b/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx @@ -1,9 +1,14 @@ +import { create } from '@bufbuild/protobuf'; import { Code, ConnectError } from '@connectrpc/connect'; import { screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StorageBackend } from '@osac/types/private'; -import { StorageBackendState, StorageProtocol } from '@osac/types/private'; +import type { StorageBackend, StorageTiersCreateResponse } from '@osac/types/private'; +import { + StorageBackendState, + StorageProtocol, + StorageTiersCreateResponseSchema, +} from '@osac/types/private'; import StorageTierCreatePage from './StorageTierCreatePage'; import { renderWithProviders } from '../../test-utils/TestProviders'; @@ -14,6 +19,11 @@ vi.mock('react-router-dom', async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate, + // useBlocker requires a data router; this test harness renders under a plain + // MemoryRouter, so LeaveFormConfirmation's blocking behavior is stubbed out + // rather than exercised here (see StorageBackendCreatePage.test.tsx for the + // same pattern). + useBlocker: () => ({ state: 'unblocked' as const }), }; }); @@ -245,6 +255,37 @@ describe('StorageTierCreatePage', () => { }); }); + it('disables Create while the submission is pending, to prevent duplicate submissions', async () => { + let resolveCreate: (() => void) | undefined; + const onStorageTierCreate = () => + new Promise((resolve) => { + resolveCreate = () => + resolve(create(StorageTiersCreateResponseSchema, { object: { id: 'new-tier-1' } })); + }); + + const { user } = renderWithProviders(, { + apiFixtures: { storageBackends: [readyBackend] }, + transportOverrides: { onStorageTierCreate }, + }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + // Once isLoading is true, PatternFly's Spinner contributes its own "Contents" + // accessible name to the button, so an exact "Create" match no longer + // resolves — match by substring instead. + await waitFor(() => { + expect(screen.getByRole('button', { name: /Create/ })).toBeDisabled(); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + resolveCreate?.(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/tiers'); + }); + }, 15000); + it('shows the ALREADY_EXISTS error as a form-level error without navigating away', async () => { const { user } = renderWithProviders(, { apiFixtures: { storageBackends: [readyBackend] }, diff --git a/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx b/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx index b8b8267b..e4a68c65 100644 --- a/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx +++ b/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx @@ -26,8 +26,10 @@ import { useTranslation } from '../../hooks/useTranslation'; import { getErrorMessage } from '../../utils/error'; import { positiveIntegerSchema } from '../../validation/positive-integer'; import { resourceNameSchema } from '../../validation/resource-name'; +import NameField from '../catalogProvision/wizard/fields/NameField'; import { CheckboxField } from '../Form/CheckboxField'; import { InputField } from '../Form/InputField'; +import LeaveFormConfirmation from '../Form/LeaveFormConfirmation'; import OsacForm from '../Form/OsacForm'; import { RadioButtonField } from '../Form/RadioButtonField'; import { SelectField } from '../Form/SelectField'; @@ -50,13 +52,13 @@ interface BackendAssociationValues { } interface StorageTierCreateFormValues { - name: string; + metadata: { name: string }; description: string; backends: [BackendAssociationValues]; } const initialValues: StorageTierCreateFormValues = { - name: '', + metadata: { name: '' }, description: '', backends: [ { @@ -73,7 +75,7 @@ const initialValues: StorageTierCreateFormValues = { const StorageTierCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { mutate, error, isPending } = useCreateStorageTier(); + const { mutateAsync, error } = useCreateStorageTier(); const { data: backends = [], isLoading: backendsLoading } = usePrivateStorageBackends({ filter: STORAGE_BACKEND_READY_LIST_FILTER, }); @@ -84,7 +86,7 @@ const StorageTierCreatePage = () => { })); const schema = Yup.object({ - name: resourceNameSchema(t), + metadata: Yup.object({ name: resourceNameSchema(t) }), description: Yup.string(), backends: Yup.array() .of( @@ -124,11 +126,11 @@ const StorageTierCreatePage = () => { { - const backend = values.backends[0]; - mutate( - { - metadata: { name: values.name }, + onSubmit={async (values) => { + try { + const backend = values.backends[0]; + await mutateAsync({ + metadata: values.metadata, spec: { description: values.description, backends: [ @@ -142,16 +144,19 @@ const StorageTierCreatePage = () => { }, ], }, - }, - { onSuccess: () => navigate(TIERS_LIST_PATH) }, - ); + }); + navigate(TIERS_LIST_PATH); + } catch { + // Surfaced via the mutation's own `error` state below; nothing further to do here. + } }} > - {({ submitForm }) => ( + {({ submitForm, isSubmitting }) => ( + - + { @@ -230,7 +235,7 @@ const StorageTierCreatePage = () => { diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx new file mode 100644 index 00000000..3e6eed99 --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx @@ -0,0 +1,253 @@ +import { create } from '@bufbuild/protobuf'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type StorageBackendsCreateRequest, + type StorageBackendsCreateResponse, + StorageBackendsCreateResponseSchema, +} from '@osac/types/private'; + +import { StorageBackendCreatePage } from './StorageBackendCreatePage'; +import type { MockTransportOverrides } from '../../test-utils/createMockConnectTransport'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + // useBlocker requires a data router; this test harness renders under a plain + // MemoryRouter, so LeaveFormConfirmation's blocking behavior is stubbed out + // rather than exercised here (its only other caller has no test coverage + // for it either). + useBlocker: () => ({ state: 'unblocked' as const }), + }; +}); + +const testBackendPassword = 'test-password'; + +const renderPage = (overrides?: MockTransportOverrides) => + renderWithProviders(, { + transportOverrides: overrides, + }); + +describe('StorageBackendCreatePage', () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + const fillValidForm = async (user: ReturnType['user']) => { + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.click(screen.getByLabelText(/^Provider/)); + await user.click(screen.getByRole('option', { name: 'VAST' })); + await user.type(screen.getByRole('textbox', { name: 'Endpoint' }), 'vast.example.com:443'); + await user.type(screen.getByLabelText(/^Username/), 'admin'); + await user.type(screen.getByLabelText(/^Password/), testBackendPassword); + }; + + it('renders the page title, breadcrumb, and all fields', () => { + renderPage(); + + expect(screen.getByRole('heading', { name: 'Create storage backend' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Storage backends' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Use credentials' })).toBeChecked(); + expect(screen.getByLabelText(/^Username/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^Password/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('renders the provider select with exactly vast, ceph, and pure options', async () => { + const { user } = renderPage(); + + await user.click(screen.getByLabelText(/^Provider/)); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options.map((option) => option.textContent)).toEqual(['VAST', 'Ceph', 'Pure']); + }); + + it('renders the password field as a masked input', () => { + renderPage(); + + const passwordField = screen.getByLabelText(/^Password/); + expect(passwordField).toHaveAttribute('type', 'password'); + }); + + it('shows a DNS-label validation error for an invalid name and does not submit', async () => { + const onStorageBackendCreate = vi.fn(); + const { user } = renderPage({ onStorageBackendCreate }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Invalid_Name'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect( + screen.getByText( + 'Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)', + ), + ).toBeInTheDocument(); + }); + expect(onStorageBackendCreate).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('shows required-field validation errors for endpoint, username, and password', async () => { + const onStorageBackendCreate = vi.fn(); + const { user } = renderPage({ onStorageBackendCreate }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.click(screen.getByLabelText(/^Provider/)); + await user.click(screen.getByRole('option', { name: 'VAST' })); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Endpoint is required')).toBeInTheDocument(); + }); + expect(screen.getByText('Username is required')).toBeInTheDocument(); + expect(screen.getByText('Password is required')).toBeInTheDocument(); + expect(onStorageBackendCreate).not.toHaveBeenCalled(); + }); + + it('shows a required error for provider when nothing is selected, not the removed oneOf message', async () => { + const onStorageBackendCreate = vi.fn(); + const { user } = renderPage({ onStorageBackendCreate }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.type(screen.getByRole('textbox', { name: 'Endpoint' }), 'vast.example.com:443'); + await user.type(screen.getByLabelText(/^Username/), 'admin'); + await user.type(screen.getByLabelText(/^Password/), testBackendPassword); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Provider is required')).toBeInTheDocument(); + }); + expect( + screen.queryByText('Provider must be one of vast, ceph, or pure'), + ).not.toBeInTheDocument(); + expect(onStorageBackendCreate).not.toHaveBeenCalled(); + }); + + it('disables Create while the submission is pending, to prevent duplicate submissions', async () => { + let resolveCreate: (() => void) | undefined; + const onStorageBackendCreate = () => + new Promise((resolve) => { + resolveCreate = () => + resolve(create(StorageBackendsCreateResponseSchema, { object: { id: 'new-backend-1' } })); + }); + + const { user } = renderPage({ onStorageBackendCreate }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + // Once isLoading is true, PatternFly's Spinner contributes its own + // "Contents" accessible name to the button, so an exact "Create" match + // no longer resolves — match by substring instead (same pattern already + // used elsewhere in this file for accessible-name additions). + await waitFor(() => { + expect(screen.getByRole('button', { name: /Create/ })).toBeDisabled(); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + resolveCreate?.(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + }, 15000); + + it('submits the expected payload and navigates to the backends list on success', async () => { + let capturedRequest: StorageBackendsCreateRequest | undefined; + const { user } = renderPage({ + onStorageBackendCreate: (req) => { + capturedRequest = req; + return create(StorageBackendsCreateResponseSchema, { + object: { id: 'new-backend-1', metadata: req.object?.metadata, spec: req.object?.spec }, + }); + }, + }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + + expect(capturedRequest?.object?.metadata?.name).toBe('vast-prod-1'); + expect(capturedRequest?.object?.spec?.provider).toBe('vast'); + expect(capturedRequest?.object?.spec?.endpoint).toBe('vast.example.com:443'); + expect(capturedRequest?.object?.spec?.credentials?.username).toBe('admin'); + expect(capturedRequest?.object?.spec?.credentials?.password).toBe(testBackendPassword); + }, 15000); + + it('hides credential fields and omits credentials from the payload when "Use credentials" is unchecked', async () => { + let capturedRequest: StorageBackendsCreateRequest | undefined; + const { user } = renderPage({ + onStorageBackendCreate: (req) => { + capturedRequest = req; + return create(StorageBackendsCreateResponseSchema, { + object: { id: 'new-backend-1', metadata: req.object?.metadata, spec: req.object?.spec }, + }); + }, + }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.click(screen.getByLabelText(/^Provider/)); + await user.click(screen.getByRole('option', { name: 'VAST' })); + await user.type(screen.getByRole('textbox', { name: 'Endpoint' }), 'vast.example.com:443'); + + await user.click(screen.getByRole('checkbox', { name: 'Use credentials' })); + + expect(screen.queryByLabelText(/^Username/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^Password/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + expect(capturedRequest?.object?.spec?.credentials).toBeUndefined(); + }, 15000); + + it('shows a form-level error and does not navigate when the name already exists', async () => { + const { user } = renderPage({ + onStorageBackendCreate: () => { + throw new ConnectError('Storage backend name already exists', Code.AlreadyExists); + }, + }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Failed to create storage backend')).toBeInTheDocument(); + }); + expect(screen.getByText('Storage backend name already exists')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }, 15000); + + it('navigates back to the backends list on cancel', async () => { + const { user } = renderPage(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + + it('navigates back to the backends list via breadcrumb', async () => { + const { user } = renderPage(); + + await user.click(screen.getByRole('button', { name: 'Storage backends' })); + + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); +}); diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx new file mode 100644 index 00000000..3db1be2c --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx @@ -0,0 +1,222 @@ +import { useNavigate } from 'react-router-dom'; +import { + ActionList, + ActionListGroup, + ActionListItem, + Alert, + Breadcrumb, + BreadcrumbItem, + Button, + PageSection, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { Formik } from 'formik'; +import type { TFunction } from 'i18next'; +import * as Yup from 'yup'; + +import { useCreateStorageBackend } from '@osac/ui-components/api/v1/private/storage-backends'; +import NameField from '@osac/ui-components/components/catalogProvision/wizard/fields/NameField'; +import { CheckboxField } from '@osac/ui-components/components/Form/CheckboxField'; +import { InputField } from '@osac/ui-components/components/Form/InputField'; +import LeaveFormConfirmation from '@osac/ui-components/components/Form/LeaveFormConfirmation'; +import OsacForm from '@osac/ui-components/components/Form/OsacForm'; +import { + SelectField, + type SelectFieldOption, +} from '@osac/ui-components/components/Form/SelectField'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import { getErrorMessage } from '@osac/ui-components/utils/error'; +import { resourceNameSchema } from '@osac/ui-components/validation/resource-name'; + +const BACKENDS_LIST_PATH = '/admin/infrastructure/storage/backends'; + +interface StorageBackendFormValues { + metadata: { name: string }; + provider: string; + endpoint: string; + description: string; + useCredentials: boolean; + credentials: { username: string; password: string }; +} + +const initialValues: StorageBackendFormValues = { + metadata: { name: '' }, + provider: '', + endpoint: '', + description: '', + useCredentials: true, + credentials: { username: '', password: '' }, +}; + +const getStorageBackendSchema = (t: TFunction) => + Yup.object({ + metadata: Yup.object({ name: resourceNameSchema(t) }), + provider: Yup.string().required(t('Provider is required')), + endpoint: Yup.string().required(t('Endpoint is required')), + description: Yup.string(), + useCredentials: Yup.boolean(), + // Credentials are optional on the backend. When "Use credentials" is checked, + // both username and password are required; when unchecked they are omitted + // from the request entirely (see onSubmit), so there is no "only one set" state. + credentials: Yup.object().when('useCredentials', { + is: true, + then: (schema) => + schema.shape({ + username: Yup.string().required(t('Username is required')), + password: Yup.string().required(t('Password is required')), + }), + otherwise: (schema) => + schema.shape({ + username: Yup.string(), + password: Yup.string(), + }), + }), + }); + +export const StorageBackendCreatePage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { mutateAsync, error } = useCreateStorageBackend(); + + const providerOptions: SelectFieldOption[] = [ + { value: 'vast', label: t('VAST') }, + { value: 'ceph', label: t('Ceph') }, + { value: 'pure', label: t('Pure') }, + ]; + + return ( + <> + + + + + + + {t('Create')} + + + {t('Create storage backend')} + + + + + { + try { + await mutateAsync({ + metadata: values.metadata, + spec: { + provider: values.provider, + endpoint: values.endpoint, + description: values.description, + // Omit credentials entirely when the user opts out — the backend + // does not require them, and a partial credentials object is never + // sent (username/password are all-or-nothing via the checkbox). + ...(values.useCredentials && { + credentials: { + username: values.credentials.username, + password: values.credentials.password, + }, + }), + }, + }); + navigate(BACKENDS_LIST_PATH); + } catch { + // Surfaced via the mutation's own `error` state below; nothing further to do here. + } + }} + > + {({ submitForm, isSubmitting, values }) => ( + + + + + + + + + + {values.useCredentials && ( + <> + + + + )} + + + + {!!error && ( + + + {getErrorMessage(error)} + + + )} + + + + + + + + + + + + + + )} + + + + ); +}; diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index c7ed45fa..6fa541cf 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -172,12 +172,16 @@ export type MockTransportOverrides = { onStorageBackendList?: ( req: StorageBackendsListRequest, ) => MessageInitShape; - onStorageBackendCreate?: (req: StorageBackendsCreateRequest) => StorageBackendsCreateResponse; + onStorageBackendCreate?: ( + req: StorageBackendsCreateRequest, + ) => StorageBackendsCreateResponse | Promise; onStorageBackendUpdate?: (req: StorageBackendsUpdateRequest) => StorageBackendsUpdateResponse; onStorageTierList?: ( req: StorageTiersListRequest, ) => MessageInitShape; - onStorageTierCreate?: (req: StorageTiersCreateRequest) => StorageTiersCreateResponse; + onStorageTierCreate?: ( + req: StorageTiersCreateRequest, + ) => StorageTiersCreateResponse | Promise; onStorageTierUpdate?: (req: StorageTiersUpdateRequest) => StorageTiersUpdateResponse; onStorageTierDelete?: ( req: StorageTiersDeleteRequest,