diff --git a/cypress/integration/snapshotCreation.spec.js b/cypress/integration/snapshotCreation.spec.js index ce989ac1d..42851a576 100644 --- a/cypress/integration/snapshotCreation.spec.js +++ b/cypress/integration/snapshotCreation.spec.js @@ -5,6 +5,23 @@ describe('test snapshot creation', () => { cy.intercept({ method: 'GET', url: 'api/resources/v1/profiles/**' }).as( 'getBillingProfileById', ); + cy.intercept( + { method: 'GET', url: 'api/resources/v1/profiles?*' }, + { + statusCode: 200, + body: { + total: 1, + items: [ + { + id: 'profileId', + profileName: 'Test Billing Profile', + description: 'Test profile for E2E tests', + billingAccountId: 'ba-test-001', + }, + ], + }, + }, + ).as('getBillingProfiles'); cy.intercept('POST', '/api/repository/v1/snapshots', { statusCode: 200, body: { @@ -84,9 +101,15 @@ describe('test snapshot creation is disabled', () => { beforeEach(() => { cy.intercept('GET', 'api/repository/v1/datasets/**').as('getDataset'); cy.intercept('GET', 'api/repository/v1/datasets/**/policies').as('getDatasetPolicies'); + cy.intercept('GET', '/api/resources/v1/profiles?*', { + statusCode: 401, + body: { + message: 'unauthorized', + }, + }); cy.intercept('GET', '/api/resources/v1/profiles/**', { - status: 401, - response: { + statusCode: 401, + body: { message: 'unauthorized', }, }); diff --git a/src/components/common/FullViewSnapshotModal/FullViewSnapshotDetails.tsx b/src/components/common/FullViewSnapshotModal/FullViewSnapshotDetails.tsx index d7795bd50..361b86586 100644 --- a/src/components/common/FullViewSnapshotModal/FullViewSnapshotDetails.tsx +++ b/src/components/common/FullViewSnapshotModal/FullViewSnapshotDetails.tsx @@ -1,7 +1,6 @@ import { BillingProfileModel } from 'generated/tdr'; -import { Box, FormLabel, TextField, Typography } from '@mui/material'; -import JadeDropdown from 'components/dataset/data/JadeDropdown'; -import { uniq } from 'lodash'; +import { FormLabel, TextField, Typography } from '@mui/material'; +import BillingProfileDropdown from 'components/dataset/data/sidebar/panels/BillingProfileDropdown'; import React from 'react'; export interface FullViewSnapshotDetailsProps { @@ -49,35 +48,15 @@ export function FullViewSnapshotDetails(props: FullViewSnapshotDetailsProps) { onChange={(e) => setSnapshotDescription(e.target.value)} /> - Do you want to use the Google Billing Project associated with this dataset or would you like - to select a different one? + Do you want to use the TDR Billing Profile associated with this dataset or would you like to + select a different one? - - - Google Billing Project - - - billingProfile.profileName !== undefined) - .map((billingProfile) => billingProfile.profileName) as string[], - )} - name="billing-profile" - onSelectedItem={(event) => - setSelectedBillingProfile( - billingProfiles.find( - (billingProfile) => billingProfile.profileName === event.target.value, - ), - ) - } - value={selectedBillingProfile?.profileName || ''} + ); diff --git a/src/components/dataset/data/DatasetDataView.tsx b/src/components/dataset/data/DatasetDataView.tsx index ae65835e4..857ffa887 100644 --- a/src/components/dataset/data/DatasetDataView.tsx +++ b/src/components/dataset/data/DatasetDataView.tsx @@ -28,11 +28,18 @@ type IProps = { dataset: DatasetModel; dispatch: Dispatch; polling: boolean; - profile: BillingProfileModel; + billingProfiles: BillingProfileModel[]; snapshotRequest: SnapshotRequest; } & RouteComponentProps<{ uuid?: string }>; -function DatasetDataView({ dataset, dispatch, match, polling, profile, snapshotRequest }: IProps) { +function DatasetDataView({ + dataset, + dispatch, + match, + polling, + billingProfiles, + snapshotRequest, +}: IProps) { const [selected, setSelected] = useState(''); const [selectedTable, setSelectedTable] = useState(undefined); const [sidebarWidth, setSidebarWidth] = useState(0); @@ -105,10 +112,10 @@ function DatasetDataView({ dataset, dispatch, match, polling, profile, snapshotR }, [datasetLoaded, dataset, selectedTable, canLink, snapshotRequest.assetName]); useEffect(() => { - if (profile.id) { + if (billingProfiles && billingProfiles.length > 0) { setCanLink(true); } - }, [profile]); + }, [billingProfiles]); const handleEnumeration = ( _limit: number, @@ -174,7 +181,7 @@ function mapStateToProps(state: TdrState) { return { dataset: state.datasets.dataset, polling: state.query.polling, - profile: state.profiles.profile, + billingProfiles: state.profiles.profiles, snapshotRequest: state.snapshots.snapshotRequest, }; } diff --git a/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.jsx b/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.jsx new file mode 100644 index 000000000..7ab0cd986 --- /dev/null +++ b/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.jsx @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { uniq } from 'lodash'; +import { Typography } from '@mui/material'; +import JadeDropdown from '../../JadeDropdown'; + +function BillingProfileDropdown({ + billingProfiles, + selectedBillingProfile, + onSelectedItem, + disabled = false, + sx = { height: '2.5rem', marginTop: '8px' }, + showLabel = true, + labelProps = { variant: 'subtitle2', marginTop: 1 }, +}) { + const handleItemSelection = (event) => { + const selectedProfile = billingProfiles.find( + (billingProfile) => billingProfile.profileName === event.target.value, + ); + onSelectedItem(selectedProfile); + }; + + const profileOptions = uniq( + billingProfiles + .filter((billingProfile) => billingProfile.profileName !== undefined) + .map((billingProfile) => billingProfile.profileName), + ); + + return ( + <> + {showLabel && Billing Profile} + + + ); +} + +BillingProfileDropdown.propTypes = { + billingProfiles: PropTypes.array.isRequired, + disabled: PropTypes.bool, + labelProps: PropTypes.object, + onSelectedItem: PropTypes.func.isRequired, + selectedBillingProfile: PropTypes.object, + showLabel: PropTypes.bool, + sx: PropTypes.object, +}; + +export default BillingProfileDropdown; diff --git a/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.test.tsx b/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.test.tsx new file mode 100644 index 000000000..12f9dd60b --- /dev/null +++ b/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.test.tsx @@ -0,0 +1,201 @@ +import { mount } from 'cypress/react'; +import { ThemeProvider } from '@mui/material/styles'; +import React from 'react'; +import globalTheme from 'modules/theme'; +import BillingProfileDropdown from './BillingProfileDropdown'; + +interface BillingProfile { + id: string; + profileName?: string; + [key: string]: any; +} + +const mockBillingProfiles: BillingProfile[] = [ + { + id: 'profile-1', + profileName: 'Default Profile', + }, + { + id: 'profile-2', + profileName: 'Alternative Profile', + }, + { + id: 'profile-3', + profileName: 'Test Profile', + }, +]; + +const setUp = (overrideProps = {}) => { + const props = { + billingProfiles: mockBillingProfiles, + selectedBillingProfile: mockBillingProfiles[0], + onSelectedItem: cy.stub(), + ...overrideProps, + }; + + mount( + + + , + ); + + return props; +}; + +describe('BillingProfileDropdown Component', () => { + // No need for beforeEach since we create fresh stubs in each test + + describe('Rendering', () => { + it('should render the billing profile label by default', () => { + setUp(); + + cy.contains('Billing Profile').should('be.visible'); + }); + + it('should not render label when showLabel is false', () => { + setUp({ showLabel: false }); + + cy.contains('Billing Profile').should('not.exist'); + }); + + it('should render dropdown with correct initial value', () => { + setUp(); + + cy.get('[data-cy="billingProfile"]').should('contain', 'Default Profile'); + }); + }); + + describe('Profile Selection', () => { + it('should display all available billing profiles', () => { + setUp(); + + cy.get('[data-cy="billingProfile"]').click(); + cy.contains('Default Profile').should('exist'); + cy.contains('Alternative Profile').should('exist'); + cy.contains('Test Profile').should('exist'); + }); + + it('should call onSelectedItem when profile is selected', () => { + const props = setUp(); + + cy.get('[data-cy="billingProfile"]').click(); + cy.contains('Alternative Profile').click(); + + cy.wrap(props.onSelectedItem).should('have.been.calledOnce'); + cy.wrap(props.onSelectedItem).should('have.been.calledWith', mockBillingProfiles[1]); + }); + + it('should filter out profiles without profileName', () => { + const profilesWithUndefined = [ + ...mockBillingProfiles, + { id: 'profile-4' }, // Missing profileName + { id: 'profile-5', profileName: undefined }, + ]; + + setUp({ billingProfiles: profilesWithUndefined }); + + cy.get('[data-cy="billingProfile"]').click(); + cy.contains('Default Profile').should('exist'); + cy.contains('Alternative Profile').should('exist'); + cy.contains('Test Profile').should('exist'); + // Profiles without profileName should not appear + cy.get('[data-cy="menuItem-undefined"]').should('not.exist'); + }); + }); + + describe('Disabled State', () => { + it('should be disabled when only one profile is available', () => { + setUp({ billingProfiles: [mockBillingProfiles[0]] }); + + cy.get('[data-cy="billingProfile"]').should('have.class', 'Mui-disabled'); + }); + + it('should be disabled when disabled prop is true', () => { + setUp({ disabled: true }); + + cy.get('[data-cy="billingProfile"]').should('have.class', 'Mui-disabled'); + }); + + it('should be enabled when multiple profiles available and not explicitly disabled', () => { + setUp(); + + cy.get('[data-cy="billingProfile"]').should('not.have.attr', 'aria-disabled'); + }); + }); + + describe('Empty State', () => { + it('should handle empty billing profiles array', () => { + setUp({ billingProfiles: [], selectedBillingProfile: null }); + + cy.get('[data-cy="billingProfile"]').should('have.value', ''); + cy.get('[data-cy="billingProfile"]').should('have.class', 'Mui-disabled'); + }); + + it('should handle null selectedBillingProfile', () => { + setUp({ selectedBillingProfile: null }); + + cy.get('[data-cy="billingProfile"]').should('have.value', ''); + }); + }); + + describe('Styling and Props', () => { + it('should apply custom sx props', () => { + const customSx = { height: '3rem', marginTop: '16px', backgroundColor: 'red' }; + setUp({ sx: customSx }); + + cy.get('[data-cy="billingProfile"]').should('exist'); + // Note: Testing exact styles is complex in Cypress, but we can verify the component renders + }); + + it('should handle duplicate profile names correctly', () => { + const duplicateProfiles = [ + { id: 'profile-1', profileName: 'Same Name' }, + { id: 'profile-2', profileName: 'Same Name' }, + { id: 'profile-3', profileName: 'Different Name' }, + ]; + + setUp({ billingProfiles: duplicateProfiles }); + + cy.get('[data-cy="billingProfile"]').click(); + // Should only show unique profile names + cy.contains('Same Name').should('exist'); + cy.contains('Different Name').should('exist'); + }); + }); + + describe('Integration', () => { + it('should work with real-world profile data structure', () => { + it('should call onSelectedItem with the correct profile when selection changes', () => { + const realWorldProfiles = [ + { + id: 'bp-12345', + profileName: 'Production Environment', + billingAccountId: 'ba-prod-001', + description: 'Production billing profile for live workloads', + }, + { + id: 'bp-67890', + profileName: 'Development Environment', + billingAccountId: 'ba-dev-001', + description: 'Development billing profile for testing', + }, + ]; + + const props = setUp({ + billingProfiles: realWorldProfiles, + selectedBillingProfile: realWorldProfiles[0], + }); + + cy.get('[data-cy="billingProfile"]').should('contain', 'Production Environment'); + + cy.get('[data-cy="billingProfile"]').click(); + cy.contains('Development Environment').click(); + + cy.wrap(props.onSelectedItem).should('have.been.calledOnce'); + cy.wrap(props.onSelectedItem.getCall(0).args[0]) + .should('have.property', 'id', 'bp-67890') + .should('have.property', 'profileName', 'Development Environment'); + }); + }); + }); +}); diff --git a/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.tsx b/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.tsx new file mode 100644 index 000000000..1463a3ba3 --- /dev/null +++ b/src/components/dataset/data/sidebar/panels/BillingProfileDropdown.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { uniq } from 'lodash'; +import { Typography, TypographyProps, SelectChangeEvent } from '@mui/material'; +import JadeDropdown from '../../JadeDropdown'; + +export interface BillingProfile { + id?: string; + profileName?: string; + [key: string]: any; +} + +export interface BillingProfileDropdownProps { + readonly billingProfiles: BillingProfile[]; + readonly selectedBillingProfile?: BillingProfile | null; + readonly onSelectedItem: (selectedProfile: BillingProfile | undefined) => void; + readonly disabled?: boolean; + readonly sx?: React.CSSProperties; + readonly showLabel?: boolean; + readonly labelProps?: TypographyProps; +} + +function BillingProfileDropdown({ + billingProfiles, + selectedBillingProfile, + onSelectedItem, + disabled = false, + sx = { height: '2.5rem', marginTop: '8px' }, + showLabel = true, + labelProps = { variant: 'subtitle2', marginTop: 1 }, +}: BillingProfileDropdownProps) { + const handleItemSelection = (event: SelectChangeEvent) => { + const selectedProfile = billingProfiles.find( + (billingProfile) => billingProfile.profileName === event.target.value, + ); + onSelectedItem(selectedProfile); + }; + + const profileOptions = uniq( + billingProfiles + .filter((billingProfile) => billingProfile.profileName !== undefined) + .map((billingProfile) => billingProfile.profileName as string), + ); + + return ( + <> + {showLabel && Billing Profile} + + + ); +} + +export default BillingProfileDropdown; diff --git a/src/components/dataset/data/sidebar/panels/CreateSnapshotPanel.jsx b/src/components/dataset/data/sidebar/panels/CreateSnapshotPanel.jsx index be8a174ae..d95d3444f 100644 --- a/src/components/dataset/data/sidebar/panels/CreateSnapshotPanel.jsx +++ b/src/components/dataset/data/sidebar/panels/CreateSnapshotPanel.jsx @@ -2,11 +2,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import { withStyles } from '@mui/styles'; import { connect } from 'react-redux'; - import { Button, Divider, TextField, Typography } from '@mui/material'; import { snapshotCreateDetails } from 'actions/index'; import { SnapshotRequestContentsModelModeEnum } from 'generated/tdr'; import CreateSnapshotDropdown from '../CreateSnapshotDropdown'; +import BillingProfileDropdown from './BillingProfileDropdown'; import ShareSnapshot from './ShareSnapshot'; const styles = (theme) => ({ @@ -51,10 +51,12 @@ export class CreateSnapshotPanel extends React.PureComponent { name, description, assetName, + selectedBillingProfile: null, }; } static propTypes = { + billingProfiles: PropTypes.array, classes: PropTypes.object, dataset: PropTypes.object, dispatch: PropTypes.func, @@ -64,9 +66,19 @@ export class CreateSnapshotPanel extends React.PureComponent { switchPanels: PropTypes.func, }; + componentDidMount() { + const { billingProfiles, dataset } = this.props; + const defaultBillingProfile = billingProfiles.find( + (profile) => profile.id === dataset.defaultProfileId, + ); + this.setState({ + selectedBillingProfile: defaultBillingProfile || billingProfiles[0], + }); + } + saveNameAndDescription = () => { const { dispatch, switchPanels, filterData, dataset } = this.props; - const { name, description, assetName } = this.state; + const { name, description, assetName, selectedBillingProfile } = this.state; dispatch( snapshotCreateDetails({ name, @@ -75,6 +87,7 @@ export class CreateSnapshotPanel extends React.PureComponent { dataset, assetName, filterData, + billingProfileId: selectedBillingProfile?.id, }), ); switchPanels(ShareSnapshot); @@ -88,8 +101,8 @@ export class CreateSnapshotPanel extends React.PureComponent { }; render() { - const { classes, dataset, handleCreateSnapshot } = this.props; - const { name, description, assetName } = this.state; + const { classes, dataset, handleCreateSnapshot, billingProfiles } = this.props; + const { name, description, assetName, selectedBillingProfile } = this.state; return (
@@ -124,6 +137,14 @@ export class CreateSnapshotPanel extends React.PureComponent { value={assetName} data-cy="selectAsset" /> + + + this.setState({ selectedBillingProfile: selectedProfile }) + } + />
@@ -135,7 +156,7 @@ export class CreateSnapshotPanel extends React.PureComponent { color="primary" disableElevation onClick={this.saveNameAndDescription} - disabled={assetName === '' || name === ''} + disabled={assetName === '' || name === '' || !selectedBillingProfile?.id} data-cy="next" > Next @@ -152,6 +173,7 @@ function mapStateToProps(state) { snapshots: state.snapshots, dataset: state.datasets.dataset, filterData: state.query.filterData, + billingProfiles: state.profiles.profiles, }; } diff --git a/src/components/dataset/data/sidebar/panels/CreateSnapshotPanel.test.tsx b/src/components/dataset/data/sidebar/panels/CreateSnapshotPanel.test.tsx new file mode 100644 index 000000000..2b94223fc --- /dev/null +++ b/src/components/dataset/data/sidebar/panels/CreateSnapshotPanel.test.tsx @@ -0,0 +1,521 @@ +import { mount } from 'cypress/react'; +import { Router } from 'react-router-dom'; +import { ThemeProvider } from '@mui/material/styles'; +import { Provider } from 'react-redux'; +import React from 'react'; +import createMockStore from 'redux-mock-store'; +import { routerMiddleware } from 'connected-react-router'; +import history from 'modules/hist'; +import globalTheme from 'modules/theme'; +import { initialSnapshotState } from 'reducers/snapshot'; +import { SnapshotRequestContentsModelModeEnum } from 'generated/tdr'; +import CreateSnapshotPanel from './CreateSnapshotPanel'; + +const mockDataset = { + id: 'dataset-1', + name: 'Test Dataset', + defaultProfileId: 'profile-1', + schema: { + assets: [ + { name: 'asset1', rootTable: 'table1', rootColumn: 'id' }, + { name: 'asset2', rootTable: 'table2', rootColumn: 'id' }, + ], + }, +}; + +const mockBillingProfiles = [ + { + id: 'profile-1', + profileName: 'Default Profile', + }, + { + id: 'profile-2', + profileName: 'Alternative Profile', + }, +]; + +const mockFilterData = { + someFilter: 'someValue', +}; + +const mockStore = createMockStore([routerMiddleware(history)]); + +let defaultProps: any; + +const setUp = (overrideState = {}, overrideProps = {}) => { + const initialState = { + snapshots: { + ...initialSnapshotState, + snapshotRequest: { + name: 'Test Snapshot', + description: 'Test Description', + assetName: 'asset1', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + }, + }, + datasets: { + dataset: mockDataset, + }, + query: { + filterData: mockFilterData, + }, + profiles: { + profiles: mockBillingProfiles, + }, + ...overrideState, + }; + + const store = mockStore(initialState); + cy.spy(store, 'dispatch').as('dispatchSpy'); + + const props = { + ...defaultProps, + ...overrideProps, + }; + + mount( + + + + + + + , + ); + + return { store }; +}; + +describe('CreateSnapshotPanel Component', () => { + beforeEach(() => { + // Initialize stubs before each test + defaultProps = { + handleCreateSnapshot: cy.stub(), + switchPanels: cy.stub(), + }; + }); + + describe('Rendering', () => { + it('should render all required form elements', () => { + setUp(); + + // Check main heading + cy.contains('Add Details').should('be.visible'); + + // Check snapshot name field + cy.contains('Snapshot Name').should('be.visible'); + cy.get('[data-cy="textFieldName"] input').should('be.visible'); + + // Check description field + cy.contains('Description').should('be.visible'); + cy.get('#snapshotDescription').should('be.visible'); + + // Check asset dropdown + cy.get('[data-cy="selectAsset"]').should('be.visible'); + + // Check billing profile section + cy.contains('Billing Profile').should('be.visible'); + cy.get('[data-cy="billingProfile"]').should('be.visible'); + + // Check buttons + cy.contains('Cancel').should('be.visible'); + cy.get('[data-cy="next"]').should('be.visible'); + }); + + it('should render with pre-filled values from state', () => { + setUp(); + + cy.get('[data-cy="textFieldName"] input').should('have.value', 'Test Snapshot'); + cy.get('#snapshotDescription').should('have.value', 'Test Description'); + }); + + it('should set default billing profile based on dataset defaultProfileId', () => { + setUp(); + + cy.get('[data-cy="billingProfile"]').should('contain', 'Default Profile'); + }); + + it('should set first billing profile as default when dataset has no defaultProfileId', () => { + const overrideState = { + datasets: { + dataset: { + ...mockDataset, + defaultProfileId: null, + }, + }, + }; + + setUp(overrideState); + + cy.get('[data-cy="billingProfile"]').should('contain', 'Default Profile'); + }); + }); + + describe('Form Interactions', () => { + it('should update snapshot name when typing', () => { + setUp(); + + cy.get('[data-cy="textFieldName"] input').clear().type('New Snapshot Name'); + cy.get('[data-cy="textFieldName"] input').should('have.value', 'New Snapshot Name'); + }); + + it('should update description when typing', () => { + setUp(); + + cy.get('#snapshotDescription').clear().type('New description text'); + cy.get('#snapshotDescription').should('have.value', 'New description text'); + }); + + it('should update selected asset when dropdown selection changes', () => { + setUp(); + + // Verify initial asset selection + cy.get('[data-cy="selectAsset"]').should('exist'); + + // Click the asset dropdown to open it + cy.get('[data-cy="selectAsset"]').click(); + + // Select a different asset + cy.get('[data-cy="menuItem-asset2"]').click(); + + // Verify the dropdown now shows the new selection + cy.get('[data-cy="selectAsset"]').should('contain', 'asset2'); + }); + + it('should update billing profile selection', () => { + setUp(); + + cy.get('[data-cy="billingProfile"]').click(); + cy.contains('Alternative Profile').click(); + cy.get('[data-cy="billingProfile"]').should('contain', 'Alternative Profile'); + }); + }); + + describe('Button States', () => { + it('should disable Next button when required fields are empty', () => { + const overrideState = { + snapshots: { + ...initialSnapshotState, + snapshotRequest: { + name: '', + description: '', + assetName: '', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + }, + }, + }; + + setUp(overrideState); + + cy.get('[data-cy="next"]').should('be.disabled'); + }); + + it('should disable Next button when name is empty', () => { + const overrideState = { + snapshots: { + ...initialSnapshotState, + snapshotRequest: { + name: '', + description: 'Test Description', + assetName: 'asset1', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + }, + }, + }; + + setUp(overrideState); + + cy.get('[data-cy="next"]').should('be.disabled'); + }); + + it('should disable Next button when assetName is empty', () => { + const overrideState = { + snapshots: { + ...initialSnapshotState, + snapshotRequest: { + name: 'Test Snapshot', + description: 'Test Description', + assetName: '', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + }, + }, + }; + + setUp(overrideState); + + cy.get('[data-cy="next"]').should('be.disabled'); + }); + + it('should disable Next button when no billing profile is selected', () => { + const overrideState = { + profiles: { + profiles: [], + }, + }; + + setUp(overrideState); + + cy.get('[data-cy="next"]').should('be.disabled'); + }); + + it('should enable Next button when all required fields are filled', () => { + setUp(); + + cy.get('[data-cy="next"]').should('not.be.disabled'); + }); + }); + + describe('Actions and Dispatches', () => { + it('should call handleCreateSnapshot with false when Cancel is clicked', () => { + setUp(); + + cy.contains('Cancel').click(); + cy.wrap(defaultProps.handleCreateSnapshot).should('have.been.calledWith', false); + }); + + it('should dispatch snapshotCreateDetails and call switchPanels when Next is clicked', () => { + setUp(); + + cy.get('[data-cy="next"]').click(); + + cy.get('@dispatchSpy').should('have.been.calledWith', { + type: 'SNAPSHOT_CREATE_DETAILS', + payload: { + name: 'Test Snapshot', + description: 'Test Description', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + assetName: 'asset1', + filterData: mockFilterData, + dataset: mockDataset, + authDomain: undefined, + billingProfileId: 'profile-1', + }, + }); + + cy.wrap(defaultProps.switchPanels).should('have.been.calledOnce'); + }); + + it('should dispatch with updated form values when Next is clicked', () => { + setUp(); + + // Update form values + cy.get('[data-cy="textFieldName"] input').clear().type('Updated Name'); + cy.get('#snapshotDescription').clear().type('Updated Description'); + + // Select different billing profile + cy.get('[data-cy="billingProfile"]').click(); + cy.contains('Alternative Profile').click(); + + cy.get('[data-cy="next"]').click(); + + cy.get('@dispatchSpy').should('have.been.calledWith', { + type: 'SNAPSHOT_CREATE_DETAILS', + payload: { + name: 'Updated Name', + description: 'Updated Description', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + assetName: 'asset1', + filterData: mockFilterData, + dataset: mockDataset, + authDomain: undefined, + billingProfileId: 'profile-2', + }, + }); + }); + + it('should dispatch with updated asset selection when Next is clicked', () => { + setUp(); + + // Select different asset + cy.get('[data-cy="selectAsset"]').click(); + cy.get('[data-cy="menuItem-asset2"]').click(); + + cy.get('[data-cy="next"]').click(); + + cy.get('@dispatchSpy').should('have.been.calledWith', { + type: 'SNAPSHOT_CREATE_DETAILS', + payload: { + name: 'Test Snapshot', + description: 'Test Description', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + assetName: 'asset2', + filterData: mockFilterData, + dataset: mockDataset, + authDomain: undefined, + billingProfileId: 'profile-1', + }, + }); + }); + }); + + describe('Component State Management', () => { + it('should initialize component state from props correctly', () => { + const overrideState = { + snapshots: { + ...initialSnapshotState, + snapshotRequest: { + name: 'Initial Name', + description: 'Initial Description', + assetName: 'asset2', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + }, + }, + }; + + setUp(overrideState); + + // Verify component initializes with values from Redux state + cy.get('[data-cy="textFieldName"] input').should('have.value', 'Initial Name'); + cy.get('#snapshotDescription').should('have.value', 'Initial Description'); + }); + }); + + describe('Props Integration', () => { + it('should call switchPanels prop with ShareSnapshot component', () => { + setUp(); + + cy.get('[data-cy="next"]').click(); + + cy.wrap(defaultProps.switchPanels).should('have.been.calledOnce'); + }); + + it('should use handleCreateSnapshot prop for cancel functionality', () => { + setUp(); + + cy.contains('Cancel').click(); + + cy.wrap(defaultProps.handleCreateSnapshot).should('have.been.calledOnceWith', false); + }); + }); + + describe('Redux State Mapping', () => { + it('should correctly map state from multiple Redux slices', () => { + const customState = { + snapshots: { + ...initialSnapshotState, + snapshotRequest: { + name: 'Redux Name', + description: 'Redux Description', + assetName: 'custom-asset', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + }, + }, + datasets: { + dataset: { + ...mockDataset, + name: 'Custom Dataset', + }, + }, + query: { + filterData: { customFilter: 'customValue' }, + }, + profiles: { + profiles: [{ id: 'custom-profile', profileName: 'Custom Profile' }], + }, + }; + + setUp(customState); + + // Verify component uses values from different Redux slices + cy.get('[data-cy="textFieldName"] input').should('have.value', 'Redux Name'); + cy.get('[data-cy="billingProfile"]').should('contain', 'Custom Profile'); + + // Submit and verify all state is correctly passed to action + cy.get('[data-cy="next"]').click(); + + cy.get('@dispatchSpy').should('have.been.calledWith', { + type: 'SNAPSHOT_CREATE_DETAILS', + payload: { + name: 'Redux Name', + description: 'Redux Description', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + assetName: 'custom-asset', + filterData: { customFilter: 'customValue' }, + dataset: customState.datasets.dataset, + authDomain: undefined, + billingProfileId: 'custom-profile', + }, + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty billing profiles array', () => { + const overrideState = { + profiles: { + profiles: [], + }, + datasets: { + dataset: { + ...mockDataset, + defaultProfileId: null, + }, + }, + }; + + setUp(overrideState); + + cy.get('[data-cy="billingProfile"]').should('have.value', ''); + cy.get('[data-cy="next"]').should('be.disabled'); + }); + + it('should handle billing profiles without profileName', () => { + const overrideState = { + profiles: { + profiles: [ + { id: 'profile-1' }, // Missing profileName + { id: 'profile-2', profileName: 'Valid Profile' }, + ], + }, + }; + + setUp(overrideState); + + // Should only show profiles with profileName defined + cy.get('[data-cy="billingProfile"]').click(); + cy.contains('Valid Profile').should('exist'); + }); + + it('should handle missing dataset schema assets', () => { + const overrideState = { + datasets: { + dataset: { + ...mockDataset, + schema: { + assets: [], + }, + }, + }, + }; + + setUp(overrideState); + + // Should still render the asset dropdown even with no assets + cy.get('[data-cy="selectAsset"]').should('exist'); + }); + + it('should handle undefined or null snapshot request values', () => { + const overrideState = { + snapshots: { + ...initialSnapshotState, + snapshotRequest: { + name: null, + description: undefined, + assetName: '', + mode: SnapshotRequestContentsModelModeEnum.ByQuery, + }, + }, + }; + + setUp(overrideState); + + // Should handle null/undefined values gracefully + cy.get('[data-cy="textFieldName"] input').should('have.value', ''); + cy.get('#snapshotDescription').should('have.value', ''); + cy.get('[data-cy="next"]').should('be.disabled'); + }); + }); +}); diff --git a/src/components/dataset/data/sidebar/panels/FilterPanel.jsx b/src/components/dataset/data/sidebar/panels/FilterPanel.jsx index 0c33a27e2..a35968afe 100644 --- a/src/components/dataset/data/sidebar/panels/FilterPanel.jsx +++ b/src/components/dataset/data/sidebar/panels/FilterPanel.jsx @@ -219,8 +219,7 @@ export class FilterPanel extends React.PureComponent { }); const billingErrorMessage = - "You cannot create a snapshot because you do not have access to the dataset's billing profile."; - + 'You cannot create a snapshot because you do not have access to any TDR billing profiles.'; return (
@@ -343,6 +342,7 @@ function mapStateToProps(state) { filterData: state.query.filterData, polling: state.query.polling, token: state.user.delegateToken, + billingProfiles: state.profiles.profiles, }; } diff --git a/src/components/dataset/data/sidebar/panels/ShareSnapshot.jsx b/src/components/dataset/data/sidebar/panels/ShareSnapshot.jsx index 225a17ddc..d260dd994 100644 --- a/src/components/dataset/data/sidebar/panels/ShareSnapshot.jsx +++ b/src/components/dataset/data/sidebar/panels/ShareSnapshot.jsx @@ -125,6 +125,7 @@ export class ShareSnapshot extends React.PureComponent { description: snapshotRequest.description, mode: snapshotRequest.mode, assetName: snapshotRequest.assetName, + billingProfileId: snapshotRequest.billingProfileId, dataset, filterData, authDomain,