chore: migrate clusters from toolkit to react-query (#2214)

This commit is contained in:
Oleg Shur 2022-06-28 15:15:12 +03:00 committed by GitHub
parent 0efbd130b9
commit a4046d46ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 274 additions and 572 deletions

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { Suspense, useCallback } from 'react';
import { Routes, Route, useLocation } from 'react-router-dom'; import { Routes, Route, useLocation } from 'react-router-dom';
import { GIT_TAG, GIT_COMMIT } from 'lib/constants'; import { GIT_TAG, GIT_COMMIT } from 'lib/constants';
import { clusterPath, getNonExactPath } from 'lib/paths'; import { clusterPath, getNonExactPath } from 'lib/paths';
@ -10,12 +10,6 @@ import Version from 'components/Version/Version';
import Alerts from 'components/Alerts/Alerts'; import Alerts from 'components/Alerts/Alerts';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import theme from 'theme/theme'; import theme from 'theme/theme';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import {
fetchClusters,
getClusterList,
getAreClustersFulfilled,
} from 'redux/reducers/clusters/clustersSlice';
import * as S from './App.styled'; import * as S from './App.styled';
import Logo from './common/Logo/Logo'; import Logo from './common/Logo/Logo';
@ -23,9 +17,6 @@ import GitIcon from './common/Icons/GitIcon';
import DiscordIcon from './common/Icons/DiscordIcon'; import DiscordIcon from './common/Icons/DiscordIcon';
const App: React.FC = () => { const App: React.FC = () => {
const dispatch = useAppDispatch();
const areClustersFulfilled = useAppSelector(getAreClustersFulfilled);
const clusters = useAppSelector(getClusterList);
const [isSidebarVisible, setIsSidebarVisible] = React.useState(false); const [isSidebarVisible, setIsSidebarVisible] = React.useState(false);
const onBurgerClick = () => setIsSidebarVisible(!isSidebarVisible); const onBurgerClick = () => setIsSidebarVisible(!isSidebarVisible);
const closeSidebar = useCallback(() => setIsSidebarVisible(false), []); const closeSidebar = useCallback(() => setIsSidebarVisible(false), []);
@ -35,10 +26,6 @@ const App: React.FC = () => {
closeSidebar(); closeSidebar();
}, [location, closeSidebar]); }, [location, closeSidebar]);
React.useEffect(() => {
dispatch(fetchClusters());
}, [dispatch]);
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<S.Layout> <S.Layout>
@ -90,10 +77,9 @@ const App: React.FC = () => {
<S.Container> <S.Container>
<S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}> <S.Sidebar aria-label="Sidebar" $visible={isSidebarVisible}>
<Nav <Suspense fallback={<PageLoader />}>
clusters={clusters} <Nav />
areClustersFulfilled={areClustersFulfilled} </Suspense>
/>
</S.Sidebar> </S.Sidebar>
<S.Overlay <S.Overlay
$visible={isSidebarVisible} $visible={isSidebarVisible}
@ -103,7 +89,6 @@ const App: React.FC = () => {
aria-hidden="true" aria-hidden="true"
aria-label="Overlay" aria-label="Overlay"
/> />
{areClustersFulfilled ? (
<Routes> <Routes>
{['/', '/ui', '/ui/clusters'].map((path) => ( {['/', '/ui', '/ui/clusters'].map((path) => (
<Route <Route
@ -117,9 +102,6 @@ const App: React.FC = () => {
element={<ClusterPage />} element={<ClusterPage />}
/> />
</Routes> </Routes>
) : (
<PageLoader />
)}
</S.Container> </S.Container>
<S.AlertsContainer role="toolbar"> <S.AlertsContainer role="toolbar">
<Alerts /> <Alerts />

View file

@ -1,12 +1,7 @@
import React, { Suspense } from 'react'; import React, { Suspense } from 'react';
import { useSelector } from 'react-redux';
import { Routes, Navigate, Route, Outlet } from 'react-router-dom'; import { Routes, Navigate, Route, Outlet } from 'react-router-dom';
import useAppParams from 'lib/hooks/useAppParams'; import useAppParams from 'lib/hooks/useAppParams';
import { ClusterFeaturesEnum } from 'generated-sources'; import { ClusterFeaturesEnum } from 'generated-sources';
import {
getClustersFeatures,
getClustersReadonlyStatus,
} from 'redux/reducers/clusters/clustersSlice';
import { import {
clusterBrokerRelativePath, clusterBrokerRelativePath,
clusterConnectorsRelativePath, clusterConnectorsRelativePath,
@ -23,6 +18,7 @@ import Breadcrumb from 'components/common/Breadcrumb/Breadcrumb';
import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route'; import { BreadcrumbRoute } from 'components/common/Breadcrumb/Breadcrumb.route';
import { BreadcrumbProvider } from 'components/common/Breadcrumb/Breadcrumb.provider'; import { BreadcrumbProvider } from 'components/common/Breadcrumb/Breadcrumb.provider';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
import useClusters from 'lib/hooks/api/useClusters';
const Brokers = React.lazy(() => import('components/Brokers/Brokers')); const Brokers = React.lazy(() => import('components/Brokers/Brokers'));
const Topics = React.lazy(() => import('components/Topics/Topics')); const Topics = React.lazy(() => import('components/Topics/Topics'));
@ -35,34 +31,25 @@ const ConsumerGroups = React.lazy(
const Cluster: React.FC = () => { const Cluster: React.FC = () => {
const { clusterName } = useAppParams<ClusterNameRoute>(); const { clusterName } = useAppParams<ClusterNameRoute>();
const isReadOnly = useSelector(getClustersReadonlyStatus(clusterName)); const { data } = useClusters();
const features = useSelector(getClustersFeatures(clusterName)); const contextValue = React.useMemo(() => {
const cluster = data?.find(({ name }) => name === clusterName);
const features = cluster?.features || [];
const hasKafkaConnectConfigured = features.includes( return {
isReadOnly: cluster?.readOnly || false,
hasKafkaConnectConfigured: features.includes(
ClusterFeaturesEnum.KAFKA_CONNECT ClusterFeaturesEnum.KAFKA_CONNECT
); ),
const hasSchemaRegistryConfigured = features.includes( hasSchemaRegistryConfigured: features.includes(
ClusterFeaturesEnum.SCHEMA_REGISTRY ClusterFeaturesEnum.SCHEMA_REGISTRY
); ),
const isTopicDeletionAllowed = features.includes( isTopicDeletionAllowed: features.includes(
ClusterFeaturesEnum.TOPIC_DELETION ClusterFeaturesEnum.TOPIC_DELETION
); ),
const hasKsqlDbConfigured = features.includes(ClusterFeaturesEnum.KSQL_DB); hasKsqlDbConfigured: features.includes(ClusterFeaturesEnum.KSQL_DB),
};
const contextValue = React.useMemo( }, [clusterName, data]);
() => ({
isReadOnly,
hasKafkaConnectConfigured,
hasSchemaRegistryConfigured,
isTopicDeletionAllowed,
}),
[
hasKafkaConnectConfigured,
hasSchemaRegistryConfigured,
isReadOnly,
isTopicDeletionAllowed,
]
);
return ( return (
<BreadcrumbProvider> <BreadcrumbProvider>
@ -94,7 +81,7 @@ const Cluster: React.FC = () => {
</BreadcrumbRoute> </BreadcrumbRoute>
} }
/> />
{hasSchemaRegistryConfigured && ( {contextValue.hasSchemaRegistryConfigured && (
<Route <Route
path={getNonExactPath(clusterSchemasRelativePath)} path={getNonExactPath(clusterSchemasRelativePath)}
element={ element={
@ -104,7 +91,7 @@ const Cluster: React.FC = () => {
} }
/> />
)} )}
{hasKafkaConnectConfigured && ( {contextValue.hasKafkaConnectConfigured && (
<Route <Route
path={getNonExactPath(clusterConnectsRelativePath)} path={getNonExactPath(clusterConnectsRelativePath)}
element={ element={
@ -114,7 +101,7 @@ const Cluster: React.FC = () => {
} }
/> />
)} )}
{hasKafkaConnectConfigured && ( {contextValue.hasKafkaConnectConfigured && (
<Route <Route
path={getNonExactPath(clusterConnectorsRelativePath)} path={getNonExactPath(clusterConnectorsRelativePath)}
element={ element={
@ -124,7 +111,7 @@ const Cluster: React.FC = () => {
} }
/> />
)} )}
{hasKsqlDbConfigured && ( {contextValue.hasKsqlDbConfigured && (
<Route <Route
path={getNonExactPath(clusterKsqlDbRelativePath)} path={getNonExactPath(clusterKsqlDbRelativePath)}
element={ element={

View file

@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import { ClusterFeaturesEnum } from 'generated-sources'; import { Cluster, ClusterFeaturesEnum } from 'generated-sources';
import { store } from 'redux/store'; import ClusterComponent from 'components/Cluster/Cluster';
import { onlineClusterPayload } from 'redux/reducers/clusters/__test__/fixtures'; import { screen, waitFor } from '@testing-library/react';
import Cluster from 'components/Cluster/Cluster';
import { fetchClusters } from 'redux/reducers/clusters/clustersSlice';
import { screen } from '@testing-library/react';
import { render, WithRoute } from 'lib/testHelpers'; import { render, WithRoute } from 'lib/testHelpers';
import { import {
clusterBrokersPath, clusterBrokersPath,
clusterConnectorsPath,
clusterConnectsPath, clusterConnectsPath,
clusterConsumerGroupsPath, clusterConsumerGroupsPath,
clusterKsqlDbPath, clusterKsqlDbPath,
@ -16,6 +14,9 @@ import {
clusterTopicsPath, clusterTopicsPath,
} from 'lib/paths'; } from 'lib/paths';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import fetchMock from 'fetch-mock';
import { onlineClusterPayload } from './fixtures';
const CLusterCompText = { const CLusterCompText = {
Topics: 'Topics', Topics: 'Topics',
@ -46,98 +47,79 @@ jest.mock('components/KsqlDb/KsqlDb', () => () => (
)); ));
describe('Cluster', () => { describe('Cluster', () => {
const renderComponent = (pathname: string) => { afterEach(() => fetchMock.restore());
const renderComponent = async (pathname: string, payload: Cluster[] = []) => {
const mock = fetchMock.get('/api/clusters', payload);
await act(() => {
render( render(
<WithRoute path={`${clusterPath()}/*`}> <WithRoute path={`${clusterPath()}/*`}>
<Cluster /> <ClusterComponent />
</WithRoute>, </WithRoute>,
{ initialEntries: [pathname], store } { initialEntries: [pathname] }
); );
});
return waitFor(() => expect(mock.called()).toBeTruthy());
}; };
it('renders Brokers', async () => { it('renders Brokers', async () => {
await act(() => renderComponent(clusterBrokersPath('second'))); await renderComponent(clusterBrokersPath('second'));
expect(screen.getByText(CLusterCompText.Brokers)).toBeInTheDocument(); expect(screen.getByText(CLusterCompText.Brokers)).toBeInTheDocument();
}); });
it('renders Topics', async () => { it('renders Topics', async () => {
await act(() => renderComponent(clusterTopicsPath('second'))); await renderComponent(clusterTopicsPath('second'));
expect(screen.getByText(CLusterCompText.Topics)).toBeInTheDocument(); expect(screen.getByText(CLusterCompText.Topics)).toBeInTheDocument();
}); });
it('renders ConsumerGroups', async () => { it('renders ConsumerGroups', async () => {
await act(() => renderComponent(clusterConsumerGroupsPath('second'))); await renderComponent(clusterConsumerGroupsPath('second'));
expect( expect(
screen.getByText(CLusterCompText.ConsumerGroups) screen.getByText(CLusterCompText.ConsumerGroups)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
describe('configured features', () => { describe('configured features', () => {
it('does not render Schemas if SCHEMA_REGISTRY is not configured', async () => { const itCorrectlyHandlesConfiguredSchema = (
store.dispatch( feature: ClusterFeaturesEnum,
fetchClusters.fulfilled( text: string,
[ path: string
) => {
it(`renders Schemas if ${feature} is configured`, async () => {
await renderComponent(path, [
{ {
...onlineClusterPayload, ...onlineClusterPayload,
features: [], features: [feature],
}, },
], ]);
'123' expect(screen.getByText(text)).toBeInTheDocument();
)
);
await act(() => renderComponent(clusterSchemasPath('second')));
expect(
screen.queryByText(CLusterCompText.Schemas)
).not.toBeInTheDocument();
}); });
it('renders Schemas if SCHEMA_REGISTRY is configured', async () => {
store.dispatch( it(`does not render Schemas if ${feature} is not configured`, async () => {
fetchClusters.fulfilled( await renderComponent(path, [
[ { ...onlineClusterPayload, features: [] },
{ ]);
...onlineClusterPayload, expect(screen.queryByText(text)).not.toBeInTheDocument();
features: [ClusterFeaturesEnum.SCHEMA_REGISTRY],
},
],
'123'
)
);
await act(() =>
renderComponent(clusterSchemasPath(onlineClusterPayload.name))
);
expect(screen.getByText(CLusterCompText.Schemas)).toBeInTheDocument();
}); });
it('renders Connect if KAFKA_CONNECT is configured', async () => { };
store.dispatch(
fetchClusters.fulfilled( itCorrectlyHandlesConfiguredSchema(
[ ClusterFeaturesEnum.SCHEMA_REGISTRY,
{ CLusterCompText.Schemas,
...onlineClusterPayload, clusterSchemasPath(onlineClusterPayload.name)
features: [ClusterFeaturesEnum.KAFKA_CONNECT],
},
],
'requestId'
)
); );
await act(() => itCorrectlyHandlesConfiguredSchema(
renderComponent(clusterConnectsPath(onlineClusterPayload.name)) ClusterFeaturesEnum.KAFKA_CONNECT,
CLusterCompText.Connect,
clusterConnectsPath(onlineClusterPayload.name)
); );
expect(screen.getByText(CLusterCompText.Connect)).toBeInTheDocument(); itCorrectlyHandlesConfiguredSchema(
}); ClusterFeaturesEnum.KAFKA_CONNECT,
it('renders KSQL if KSQL_DB is configured', async () => { CLusterCompText.Connect,
store.dispatch( clusterConnectorsPath(onlineClusterPayload.name)
fetchClusters.fulfilled(
[
{
...onlineClusterPayload,
features: [ClusterFeaturesEnum.KSQL_DB],
},
],
'requestId'
)
); );
await act(() => itCorrectlyHandlesConfiguredSchema(
renderComponent(clusterKsqlDbPath(onlineClusterPayload.name)) ClusterFeaturesEnum.KSQL_DB,
CLusterCompText.KsqlDb,
clusterKsqlDbPath(onlineClusterPayload.name)
); );
expect(screen.getByText(CLusterCompText.KsqlDb)).toBeInTheDocument();
});
}); });
}); });

View file

@ -9,6 +9,7 @@ export const onlineClusterPayload: Cluster = {
topicCount: 3, topicCount: 3,
bytesInPerSec: 1.55, bytesInPerSec: 1.55,
bytesOutPerSec: 9.314, bytesOutPerSec: 9.314,
readOnly: false,
features: [], features: [],
}; };
export const offlineClusterPayload: Cluster = { export const offlineClusterPayload: Cluster = {
@ -21,6 +22,7 @@ export const offlineClusterPayload: Cluster = {
bytesInPerSec: 3.42, bytesInPerSec: 3.42,
bytesOutPerSec: 4.14, bytesOutPerSec: 4.14,
features: [], features: [],
readOnly: true,
}; };
export const clustersPayload: Cluster[] = [ export const clustersPayload: Cluster[] = [

View file

@ -14,7 +14,7 @@ import * as Metrics from 'components/common/Metrics';
import { Tag } from 'components/common/Tag/Tag.styled'; import { Tag } from 'components/common/Tag/Tag.styled';
import Dropdown from 'components/common/Dropdown/Dropdown'; import Dropdown from 'components/common/Dropdown/Dropdown';
import DropdownItem from 'components/common/Dropdown/DropdownItem'; import DropdownItem from 'components/common/Dropdown/DropdownItem';
import { groupBy } from 'lodash'; import groupBy from 'lodash/groupBy';
import { Table } from 'components/common/table/Table/Table.styled'; import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell'; import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux'; import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';

View file

@ -12,7 +12,7 @@ import MultiSelect from 'react-multi-select-component';
import { Option } from 'react-multi-select-component/dist/lib/interfaces'; import { Option } from 'react-multi-select-component/dist/lib/interfaces';
import DatePicker from 'react-datepicker'; import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { groupBy } from 'lodash'; import groupBy from 'lodash/groupBy';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';
import { ErrorMessage } from '@hookform/error-message'; import { ErrorMessage } from '@hookform/error-message';
import Select from 'components/common/Select/Select'; import Select from 'components/common/Select/Select';

View file

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { chunk } from 'lodash';
import * as Metrics from 'components/common/Metrics'; import * as Metrics from 'components/common/Metrics';
import { Cluster } from 'generated-sources';
import { Tag } from 'components/common/Tag/Tag.styled'; import { Tag } from 'components/common/Tag/Tag.styled';
import { Table } from 'components/common/table/Table/Table.styled'; import { Table } from 'components/common/table/Table/Table.styled';
import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell'; import TableHeaderCell from 'components/common/table/TableHeaderCell/TableHeaderCell';
@ -9,41 +7,38 @@ import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { clusterTopicsPath } from 'lib/paths'; import { clusterTopicsPath } from 'lib/paths';
import Switch from 'components/common/Switch/Switch'; import Switch from 'components/common/Switch/Switch';
import useClusters from 'lib/hooks/api/useClusters';
import { ServerStatus } from 'generated-sources';
import * as S from './ClustersWidget.styled'; import * as S from './ClustersWidget.styled';
interface Props { const ClustersWidget: React.FC = () => {
clusters: Cluster[]; const { data } = useClusters();
onlineClusters: Cluster[];
offlineClusters: Cluster[];
}
const ClustersWidget: React.FC<Props> = ({
clusters,
onlineClusters,
offlineClusters,
}) => {
const [showOfflineOnly, setShowOfflineOnly] = React.useState<boolean>(false); const [showOfflineOnly, setShowOfflineOnly] = React.useState<boolean>(false);
const clusterList = React.useMemo(() => { const config = React.useMemo(() => {
if (showOfflineOnly) { const clusters = data || [];
return chunk(offlineClusters, 2); const offlineClusters = clusters.filter(
} ({ status }) => status === ServerStatus.OFFLINE
return chunk(clusters, 2); );
}, [clusters, offlineClusters, showOfflineOnly]); return {
list: showOfflineOnly ? offlineClusters : clusters,
online: clusters.length - offlineClusters.length,
offline: offlineClusters.length,
};
}, [data, showOfflineOnly]);
const handleSwitch = () => setShowOfflineOnly(!showOfflineOnly); const handleSwitch = () => setShowOfflineOnly(!showOfflineOnly);
return ( return (
<> <>
<Metrics.Wrapper> <Metrics.Wrapper>
<Metrics.Section> <Metrics.Section>
<Metrics.Indicator label={<Tag color="green">Online</Tag>}> <Metrics.Indicator label={<Tag color="green">Online</Tag>}>
<span>{onlineClusters.length}</span>{' '} <span>{config.online}</span>{' '}
<Metrics.LightText>clusters</Metrics.LightText> <Metrics.LightText>clusters</Metrics.LightText>
</Metrics.Indicator> </Metrics.Indicator>
<Metrics.Indicator label={<Tag color="gray">Offline</Tag>}> <Metrics.Indicator label={<Tag color="gray">Offline</Tag>}>
<span>{offlineClusters.length}</span>{' '} <span>{config.offline}</span>{' '}
<Metrics.LightText>clusters</Metrics.LightText> <Metrics.LightText>clusters</Metrics.LightText>
</Metrics.Indicator> </Metrics.Indicator>
</Metrics.Section> </Metrics.Section>
@ -56,8 +51,7 @@ const ClustersWidget: React.FC<Props> = ({
/> />
<label>Only offline clusters</label> <label>Only offline clusters</label>
</S.SwitchWrapper> </S.SwitchWrapper>
{clusterList.map((chunkItem) => ( <Table isFullwidth>
<Table key={chunkItem.map(({ name }) => name).join('-')} isFullwidth>
<thead> <thead>
<tr> <tr>
<TableHeaderCell title="Cluster name" /> <TableHeaderCell title="Cluster name" />
@ -70,7 +64,7 @@ const ClustersWidget: React.FC<Props> = ({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{chunkItem.map((cluster) => ( {config.list.map((cluster) => (
<tr key={cluster.name}> <tr key={cluster.name}>
<S.TableCell maxWidth="99px" width="350"> <S.TableCell maxWidth="99px" width="350">
{cluster.readOnly && <Tag color="blue">readonly</Tag>}{' '} {cluster.readOnly && <Tag color="blue">readonly</Tag>}{' '}
@ -96,7 +90,6 @@ const ClustersWidget: React.FC<Props> = ({
))} ))}
</tbody> </tbody>
</Table> </Table>
))}
</> </>
); );
}; };

View file

@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import {
getClusterList,
getOnlineClusters,
getOfflineClusters,
} from 'redux/reducers/clusters/clustersSlice';
import { RootState } from 'redux/interfaces';
import ClustersWidget from './ClustersWidget';
const mapStateToProps = (state: RootState) => ({
clusters: getClusterList(state),
onlineClusters: getOnlineClusters(state),
offlineClusters: getOfflineClusters(state),
});
export default connect(mapStateToProps)(ClustersWidget);

View file

@ -1,22 +1,22 @@
import React from 'react'; import React from 'react';
import { screen } from '@testing-library/react'; import { act, screen, waitFor } from '@testing-library/react';
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget'; import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import fetchMock from 'fetch-mock';
import { offlineCluster, onlineCluster, clusters } from './fixtures'; import { clustersPayload } from 'components/Cluster/__tests__/fixtures';
const setupComponent = () =>
render(
<ClustersWidget
clusters={clusters}
onlineClusters={[onlineCluster]}
offlineClusters={[offlineCluster]}
/>
);
describe('ClustersWidget', () => { describe('ClustersWidget', () => {
beforeEach(() => setupComponent()); afterEach(() => fetchMock.restore());
beforeEach(async () => {
const mock = fetchMock.get('/api/clusters', clustersPayload);
await act(() => {
render(<ClustersWidget />);
});
await waitFor(() => expect(mock.called()).toBeTruthy());
});
it('renders clusterWidget list', () => { it('renders clusterWidget list', () => {
expect(screen.getAllByRole('row').length).toBe(3); expect(screen.getAllByRole('row').length).toBe(3);

View file

@ -1,12 +0,0 @@
import React from 'react';
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
import { getByTextContent, render } from 'lib/testHelpers';
describe('ClustersWidgetContainer', () => {
it('renders ClustersWidget', () => {
render(
<ClustersWidget clusters={[]} onlineClusters={[]} offlineClusters={[]} />
);
expect(getByTextContent('Online 0 clusters')).toBeInTheDocument();
});
});

View file

@ -1,27 +0,0 @@
import { Cluster, ServerStatus } from 'generated-sources';
export const onlineCluster: Cluster = {
name: 'secondLocal',
defaultCluster: false,
status: ServerStatus.ONLINE,
brokerCount: 1,
onlinePartitionCount: 6,
topicCount: 3,
bytesInPerSec: 0.00003061819685376471,
bytesOutPerSec: 5.737800890036267,
readOnly: false,
};
export const offlineCluster: Cluster = {
name: 'local',
defaultCluster: true,
status: ServerStatus.OFFLINE,
brokerCount: 1,
onlinePartitionCount: 2,
topicCount: 2,
bytesInPerSec: 8000.00000673768,
bytesOutPerSec: 0.8153063567297119,
readOnly: true,
};
export const clusters: Cluster[] = [onlineCluster, offlineCluster];

View file

@ -1,12 +1,13 @@
import React from 'react'; import React, { Suspense } from 'react';
import PageHeading from 'components/common/PageHeading/PageHeading'; import PageHeading from 'components/common/PageHeading/PageHeading';
import ClustersWidget from 'components/Dashboard/ClustersWidget/ClustersWidget';
import ClustersWidgetContainer from './ClustersWidget/ClustersWidgetContainer';
const Dashboard: React.FC = () => ( const Dashboard: React.FC = () => (
<> <>
<PageHeading text="Dashboard" /> <PageHeading text="Dashboard" />
<ClustersWidgetContainer /> <Suspense>
<ClustersWidget />
</Suspense>
</> </>
); );

View file

@ -3,17 +3,14 @@ import Dashboard from 'components/Dashboard/Dashboard';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import { screen } from '@testing-library/dom'; import { screen } from '@testing-library/dom';
jest.mock( jest.mock('components/Dashboard/ClustersWidget/ClustersWidget', () => () => (
'components/Dashboard/ClustersWidget/ClustersWidgetContainer.ts', <div>mock-ClustersWidget</div>
() => () => <div>mock-ClustersWidgetContainer</div> ));
);
describe('Dashboard', () => { describe('Dashboard', () => {
it('renders ClustersWidget', () => { it('renders ClustersWidget', () => {
render(<Dashboard />); render(<Dashboard />);
expect(screen.getByText('Dashboard')).toBeInTheDocument(); expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect( expect(screen.getByText('mock-ClustersWidget')).toBeInTheDocument();
screen.getByText('mock-ClustersWidgetContainer')
).toBeInTheDocument();
}); });
}); });

View file

@ -10,7 +10,7 @@ import { getKsqlExecution } from 'redux/reducers/ksqlDb/selectors';
import { BASE_PARAMS } from 'lib/constants'; import { BASE_PARAMS } from 'lib/constants';
import { KsqlResponse, KsqlTableResponse } from 'generated-sources'; import { KsqlResponse, KsqlTableResponse } from 'generated-sources';
import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice'; import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice';
import { now } from 'lodash'; import now from 'lodash/now';
import { ClusterNameRoute } from 'lib/paths'; import { ClusterNameRoute } from 'lib/paths';
import type { FormValues } from './QueryForm/QueryForm'; import type { FormValues } from './QueryForm/QueryForm';

View file

@ -1,30 +1,31 @@
import useClusters from 'lib/hooks/api/useClusters';
import React from 'react'; import React from 'react';
import { Cluster } from 'generated-sources';
import ClusterMenu from './ClusterMenu'; import ClusterMenu from './ClusterMenu';
import ClusterMenuItem from './ClusterMenuItem'; import ClusterMenuItem from './ClusterMenuItem';
import * as S from './Nav.styled'; import * as S from './Nav.styled';
interface Props { const Nav: React.FC = () => {
areClustersFulfilled?: boolean; const query = useClusters();
clusters: Cluster[];
if (!query.isSuccess) {
return null;
} }
const Nav: React.FC<Props> = ({ areClustersFulfilled, clusters }) => ( return (
<aside aria-label="Sidebar Menu"> <aside aria-label="Sidebar Menu">
<S.List> <S.List>
<ClusterMenuItem to="/" title="Dashboard" isTopLevel /> <ClusterMenuItem to="/" title="Dashboard" isTopLevel />
</S.List> </S.List>
{query.data.map((cluster) => (
{areClustersFulfilled &&
clusters.map((cluster) => (
<ClusterMenu <ClusterMenu
cluster={cluster} cluster={cluster}
key={cluster.name} key={cluster.name}
singleMode={clusters.length === 1} singleMode={query.data.length === 1}
/> />
))} ))}
</aside> </aside>
); );
};
export default Nav; export default Nav;

View file

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { Cluster, ClusterFeaturesEnum } from 'generated-sources'; import { Cluster, ClusterFeaturesEnum } from 'generated-sources';
import { onlineClusterPayload } from 'redux/reducers/clusters/__test__/fixtures';
import ClusterMenu from 'components/Nav/ClusterMenu'; import ClusterMenu from 'components/Nav/ClusterMenu';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { clusterConnectorsPath } from 'lib/paths'; import { clusterConnectorsPath } from 'lib/paths';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import { onlineClusterPayload } from 'components/Cluster/__tests__/fixtures';
describe('ClusterMenu', () => { describe('ClusterMenu', () => {
const setupComponent = (cluster: Cluster, singleMode?: boolean) => ( const setupComponent = (cluster: Cluster, singleMode?: boolean) => (

View file

@ -1,29 +1,38 @@
import React from 'react'; import React from 'react';
import Nav from 'components/Nav/Nav';
import { screen, waitFor } from '@testing-library/react';
import { render } from 'lib/testHelpers';
import { import {
offlineClusterPayload, offlineClusterPayload,
onlineClusterPayload, onlineClusterPayload,
} from 'redux/reducers/clusters/__test__/fixtures'; } from 'components/Cluster/__tests__/fixtures';
import Nav from 'components/Nav/Nav'; import fetchMock from 'fetch-mock';
import { screen } from '@testing-library/react'; import { act } from 'react-dom/test-utils';
import { render } from 'lib/testHelpers'; import { Cluster } from 'generated-sources';
describe('Nav', () => { describe('Nav', () => {
afterEach(() => fetchMock.restore());
const renderComponent = async (payload: Cluster[] = []) => {
const mock = fetchMock.get('/api/clusters', payload);
await act(() => {
render(<Nav />);
});
return waitFor(() => expect(mock.called()).toBeTruthy());
};
const getDashboard = () => screen.getByText('Dashboard'); const getDashboard = () => screen.getByText('Dashboard');
const getMenuItemsCount = () => screen.getAllByRole('menuitem').length; const getMenuItemsCount = () => screen.getAllByRole('menuitem').length;
it('renders loader', () => { it('renders loader', async () => {
render(<Nav clusters={[]} />); await renderComponent();
expect(getMenuItemsCount()).toEqual(1); expect(getMenuItemsCount()).toEqual(1);
expect(getDashboard()).toBeInTheDocument(); expect(getDashboard()).toBeInTheDocument();
}); });
it('renders ClusterMenu', () => { it('renders ClusterMenu', async () => {
render( await renderComponent([onlineClusterPayload, offlineClusterPayload]);
<Nav
clusters={[onlineClusterPayload, offlineClusterPayload]}
areClustersFulfilled
/>
);
expect(screen.getAllByRole('menu').length).toEqual(3); expect(screen.getAllByRole('menu').length).toEqual(3);
expect(getMenuItemsCount()).toEqual(3); expect(getMenuItemsCount()).toEqual(3);
expect(getDashboard()).toBeInTheDocument(); expect(getDashboard()).toBeInTheDocument();

View file

@ -11,7 +11,7 @@ import {
TopicMessageEventTypeEnum, TopicMessageEventTypeEnum,
} from 'generated-sources'; } from 'generated-sources';
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import { omitBy } from 'lodash'; import omitBy from 'lodash/omitBy';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled'; import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled';
import { Option } from 'react-multi-select-component/dist/lib/interfaces'; import { Option } from 'react-multi-select-component/dist/lib/interfaces';

View file

@ -1,5 +1,5 @@
import { Partition, SeekType } from 'generated-sources'; import { Partition, SeekType } from 'generated-sources';
import { compact } from 'lodash'; import compact from 'lodash/compact';
import { Option } from 'react-multi-select-component/dist/lib/interfaces'; import { Option } from 'react-multi-select-component/dist/lib/interfaces';
export const filterOptions = (options: Option[], filter: string) => { export const filterOptions = (options: Option[], filter: string) => {

View file

@ -12,7 +12,7 @@ import {
} from 'redux/reducers/topics/topicsSlice'; } from 'redux/reducers/topics/topicsSlice';
import { useAppDispatch, useAppSelector } from 'lib/hooks/redux'; import { useAppDispatch, useAppSelector } from 'lib/hooks/redux';
import { alertAdded } from 'redux/reducers/alerts/alertsSlice'; import { alertAdded } from 'redux/reducers/alerts/alertsSlice';
import { now } from 'lodash'; import now from 'lodash/now';
import { Button } from 'components/common/Button/Button'; import { Button } from 'components/common/Button/Button';
import Editor from 'components/common/Editor/Editor'; import Editor from 'components/common/Editor/Editor';
import PageLoader from 'components/common/PageLoader/PageLoader'; import PageLoader from 'components/common/PageLoader/PageLoader';

View file

@ -1,6 +1,6 @@
import { TopicMessageSchema } from 'generated-sources'; import { TopicMessageSchema } from 'generated-sources';
import Ajv, { DefinedError } from 'ajv/dist/2020'; import Ajv, { DefinedError } from 'ajv/dist/2020';
import { upperFirst } from 'lodash'; import upperFirst from 'lodash/upperFirst';
const validateBySchema = ( const validateBySchema = (
value: string, value: string,

View file

@ -1,31 +1,25 @@
import React from 'react'; import React from 'react';
import { screen, within, act } from '@testing-library/react'; import { screen, within } from '@testing-library/react';
import App from 'components/App'; import App from 'components/App';
import { render } from 'lib/testHelpers'; import { render } from 'lib/testHelpers';
import { clustersPayload } from 'redux/reducers/clusters/__test__/fixtures';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
const burgerButtonOptions = { name: 'burger' }; const burgerButtonOptions = { name: 'burger' };
const logoutButtonOptions = { name: 'Log out' }; const logoutButtonOptions = { name: 'Log out' };
jest.mock('components/Nav/Nav', () => () => <div>Navigation</div>);
describe('App', () => { describe('App', () => {
describe('initial state', () => {
beforeEach(() => { beforeEach(() => {
render(<App />, { render(<App />, {
initialEntries: ['/'], initialEntries: ['/'],
}); });
}); });
it('shows PageLoader until clusters are fulfilled', () => {
expect(screen.getByText('Dashboard')).toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
it('correctly renders header', () => { it('correctly renders header', () => {
const header = screen.getByLabelText('Page Header'); const header = screen.getByLabelText('Page Header');
expect(header).toBeInTheDocument(); expect(header).toBeInTheDocument();
expect( expect(within(header).getByText('UI for Apache Kafka')).toBeInTheDocument();
within(header).getByText('UI for Apache Kafka')
).toBeInTheDocument();
expect(within(header).getAllByRole('separator').length).toEqual(3); expect(within(header).getAllByRole('separator').length).toEqual(3);
expect( expect(
within(header).getByRole('button', burgerButtonOptions) within(header).getByRole('button', burgerButtonOptions)
@ -34,41 +28,22 @@ describe('App', () => {
within(header).getByRole('button', logoutButtonOptions) within(header).getByRole('button', logoutButtonOptions)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('handle burger click correctly', () => { it('handle burger click correctly', () => {
const header = screen.getByLabelText('Page Header'); const burger = within(screen.getByLabelText('Page Header')).getByRole(
const burger = within(header).getByRole('button', burgerButtonOptions); 'button',
const sidebar = screen.getByLabelText('Sidebar'); burgerButtonOptions
);
const overlay = screen.getByLabelText('Overlay'); const overlay = screen.getByLabelText('Overlay');
expect(sidebar).toBeInTheDocument(); expect(screen.getByLabelText('Sidebar')).toBeInTheDocument();
expect(overlay).toBeInTheDocument(); expect(overlay).toBeInTheDocument();
expect(overlay).toHaveStyleRule('visibility: hidden'); expect(overlay).toHaveStyleRule('visibility: hidden');
expect(burger).toHaveStyleRule('display: none'); expect(burger).toHaveStyleRule('display: none');
userEvent.click(burger); userEvent.click(burger);
expect(overlay).toHaveStyleRule('visibility: visible'); expect(overlay).toHaveStyleRule('visibility: visible');
}); });
});
describe('with clusters list fetched', () => { it('Renders navigation', async () => {
it('shows Cluster list', async () => { expect(screen.getByText('Navigation')).toBeInTheDocument();
const mock = fetchMock.getOnce('/api/clusters', clustersPayload);
await act(() => {
render(<App />, {
initialEntries: ['/'],
});
});
expect(mock.called()).toBeTruthy();
const menuContainer = screen.getByLabelText('Sidebar Menu');
expect(menuContainer).toBeInTheDocument();
expect(within(menuContainer).getByText('Dashboard')).toBeInTheDocument();
expect(
within(menuContainer).getByText(clustersPayload[0].name)
).toBeInTheDocument();
expect(
within(menuContainer).getByText(clustersPayload[1].name)
).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
}); });
}); });

View file

@ -1,6 +1,6 @@
import { PER_PAGE } from 'lib/constants'; import { PER_PAGE } from 'lib/constants';
import usePagination from 'lib/hooks/usePagination'; import usePagination from 'lib/hooks/usePagination';
import { range } from 'lodash'; import range from 'lodash/range';
import React from 'react'; import React from 'react';
import PageControl from 'components/common/Pagination/PageControl'; import PageControl from 'components/common/Pagination/PageControl';
import useSearch from 'lib/hooks/useSearch'; import useSearch from 'lib/hooks/useSearch';

View file

@ -0,0 +1,8 @@
import { clustersApiClient } from 'lib/api';
import { useQuery } from 'react-query';
export default function useClusters() {
return useQuery(['clusters'], () => clustersApiClient.getClusters(), {
suspense: true,
});
}

View file

@ -1,4 +1,4 @@
import { isObject } from 'lodash'; import isObject from 'lodash/isObject';
import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice'; import { alertAdded, alertDissmissed } from 'redux/reducers/alerts/alertsSlice';
import { useAppDispatch } from 'lib/hooks/redux'; import { useAppDispatch } from 'lib/hooks/redux';

View file

@ -6,7 +6,7 @@ import {
PayloadAction, PayloadAction,
} from '@reduxjs/toolkit'; } from '@reduxjs/toolkit';
import { UnknownAsyncThunkRejectedWithValueAction } from '@reduxjs/toolkit/dist/matchers'; import { UnknownAsyncThunkRejectedWithValueAction } from '@reduxjs/toolkit/dist/matchers';
import { now } from 'lodash'; import now from 'lodash/now';
import { Alert, RootState, ServerResponse } from 'redux/interfaces'; import { Alert, RootState, ServerResponse } from 'redux/interfaces';
const alertsAdapter = createEntityAdapter<Alert>({ const alertsAdapter = createEntityAdapter<Alert>({

View file

@ -1,55 +0,0 @@
import fetchMock from 'fetch-mock-jest';
import reducer, { fetchClusters } from 'redux/reducers/clusters/clustersSlice';
import mockStoreCreator from 'redux/store/configureStore/mockStoreCreator';
import { clustersPayload } from './fixtures';
const store = mockStoreCreator;
describe('Clusters Slice', () => {
describe('Reducer', () => {
it('returns the initial state', () => {
expect(reducer(undefined, { type: fetchClusters.pending })).toEqual([]);
});
it('reacts on fetchClusters.fulfilled and returns payload', () => {
expect(
reducer([], {
type: fetchClusters.fulfilled,
payload: clustersPayload,
})
).toEqual(clustersPayload);
});
});
describe('thunks', () => {
afterEach(() => {
fetchMock.restore();
store.clearActions();
});
describe('fetchClusters', () => {
it('creates fetchClusters.fulfilled when fetched clusters', async () => {
fetchMock.getOnce('/api/clusters', clustersPayload);
await store.dispatch(fetchClusters());
expect(
store.getActions().map(({ type, payload }) => ({ type, payload }))
).toEqual([
{ type: fetchClusters.pending.type },
{ type: fetchClusters.fulfilled.type, payload: clustersPayload },
]);
});
it('creates fetchClusters.rejected when fetched clusters', async () => {
fetchMock.getOnce('/api/clusters', 422);
await store.dispatch(fetchClusters());
expect(
store.getActions().map(({ type, payload }) => ({ type, payload }))
).toEqual([
{ type: fetchClusters.pending.type },
{ type: fetchClusters.rejected.type },
]);
});
});
});
});

View file

@ -1,60 +0,0 @@
import { store } from 'redux/store';
import {
fetchClusters,
getAreClustersFulfilled,
getClusterList,
getOnlineClusters,
getOfflineClusters,
} from 'redux/reducers/clusters/clustersSlice';
import {
clustersPayload,
offlineClusterPayload,
onlineClusterPayload,
} from './fixtures';
describe('Clusters selectors', () => {
describe('Initial State', () => {
it('returns fetch status', () => {
expect(getAreClustersFulfilled(store.getState())).toBeFalsy();
});
it('returns cluster list', () => {
expect(getClusterList(store.getState())).toEqual([]);
});
it('returns online cluster list', () => {
expect(getOnlineClusters(store.getState())).toEqual([]);
});
it('returns offline cluster list', () => {
expect(getOfflineClusters(store.getState())).toEqual([]);
});
});
describe('state', () => {
beforeAll(() => {
store.dispatch(fetchClusters.fulfilled(clustersPayload, '1234'));
});
it('returns fetch status', () => {
expect(getAreClustersFulfilled(store.getState())).toBeTruthy();
});
it('returns cluster list', () => {
expect(getClusterList(store.getState())).toEqual(clustersPayload);
});
it('returns online cluster list', () => {
expect(getOnlineClusters(store.getState())).toEqual([
onlineClusterPayload,
]);
});
it('returns offline cluster list', () => {
expect(getOfflineClusters(store.getState())).toEqual([
offlineClusterPayload,
]);
});
});
});

View file

@ -1,61 +0,0 @@
import {
createAsyncThunk,
createSlice,
createSelector,
} from '@reduxjs/toolkit';
import { Cluster, ServerStatus, ClusterFeaturesEnum } from 'generated-sources';
import { clustersApiClient } from 'lib/api';
import { AsyncRequestStatus } from 'lib/constants';
import { RootState } from 'redux/interfaces';
import { createFetchingSelector } from 'redux/reducers/loader/selectors';
export const fetchClusters = createAsyncThunk(
'clusters/fetchClusters',
async () => {
const clusters: Cluster[] = await clustersApiClient.getClusters();
return clusters;
}
);
export const initialState: Cluster[] = [];
export const clustersSlice = createSlice({
name: 'clusters',
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchClusters.fulfilled, (_, { payload }) => payload);
},
});
const clustersState = ({ clusters }: RootState): Cluster[] => clusters;
const getClusterListFetchingStatus = createFetchingSelector(
'clusters/fetchClusters'
);
export const getAreClustersFulfilled = createSelector(
getClusterListFetchingStatus,
(status) => status === AsyncRequestStatus.fulfilled
);
export const getClusterList = createSelector(
clustersState,
(clusters) => clusters
);
export const getOnlineClusters = createSelector(getClusterList, (clusters) =>
clusters.filter(({ status }) => status === ServerStatus.ONLINE)
);
export const getOfflineClusters = createSelector(getClusterList, (clusters) =>
clusters.filter(({ status }) => status === ServerStatus.OFFLINE)
);
export const getClustersReadonlyStatus = (clusterName: string) =>
createSelector(
getClusterList,
(clusters): boolean =>
clusters.find(({ name }) => name === clusterName)?.readOnly || false
);
export const getClustersFeatures = (clusterName: string) =>
createSelector(
getClusterList,
(clusters): ClusterFeaturesEnum[] =>
clusters.find(({ name }) => name === clusterName)?.features || []
);
export default clustersSlice.reducer;

View file

@ -6,7 +6,7 @@ import {
ConnectorState, ConnectorState,
FullConnectorInfo, FullConnectorInfo,
} from 'generated-sources'; } from 'generated-sources';
import { sortBy } from 'lodash'; import sortBy from 'lodash/sortBy';
import { AsyncRequestStatus } from 'lib/constants'; import { AsyncRequestStatus } from 'lib/constants';
import { import {

View file

@ -1,5 +1,4 @@
import { combineReducers } from '@reduxjs/toolkit'; import { combineReducers } from '@reduxjs/toolkit';
import clusters from 'redux/reducers/clusters/clustersSlice';
import loader from 'redux/reducers/loader/loaderSlice'; import loader from 'redux/reducers/loader/loaderSlice';
import alerts from 'redux/reducers/alerts/alertsSlice'; import alerts from 'redux/reducers/alerts/alertsSlice';
import schemas from 'redux/reducers/schemas/schemasSlice'; import schemas from 'redux/reducers/schemas/schemasSlice';
@ -14,7 +13,6 @@ export default combineReducers({
alerts, alerts,
topics, topics,
topicMessages, topicMessages,
clusters,
consumerGroups, consumerGroups,
schemas, schemas,
connect, connect,

View file

@ -22,7 +22,6 @@ export default defineConfig(({ mode }) => {
'styled-components', 'styled-components',
'react-ace', 'react-ace',
], ],
lodash: ['lodash'],
}, },
}, },
}, },