TopNav: Plugin page layouts / information architecture (#53174)

* Change nav structure when topnav is enable to do initial tests with new information architecture

* Support for nested sections

* Updated

* sentance case

* Progress on plugin challange

* Rewrite to functional component

* Progress

* Updates

* Progress

* Progress on things

* missing file

* Fixing issue with runtime, need to use setter way to set component exposed via runtime

* Move PageLayoutType to grafana/data

* Fixing breadcrumb issue, adding more tests

* reverted backend change

* fix recursive issue with cleanup
This commit is contained in:
Torkel Ödegaard
2022-09-05 14:56:08 +02:00
committed by GitHub
parent a423c7f22e
commit 11de1dfe40
40 changed files with 399 additions and 248 deletions

View File

@@ -12,7 +12,7 @@ import { NotificationChannelType, NotificationChannelDTO, StoreState } from 'app
import { NotificationChannelForm } from './components/NotificationChannelForm';
import { loadNotificationChannel, testNotificationChannel, updateNotificationChannel } from './state/actions';
import { resetSecureField } from './state/reducers';
import { initialChannelState, resetSecureField } from './state/reducers';
import { mapChannelsToSelectableValue, transformSubmitData, transformTestData } from './utils/notificationChannels';
interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> {}
@@ -135,5 +135,5 @@ const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
export default connectWithCleanUp(
mapStateToProps,
mapDispatchToProps,
(state) => state.notificationChannel
(state) => (state.notificationChannel = initialChannelState)
)(EditNotificationChannelPage);

View File

@@ -66,7 +66,8 @@ const AmRoutes: FC = () => {
setIsRootRouteEditMode(false);
};
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
const handleSave = (data: Partial<FormAmRoute>) => {
if (!result) {
return;

View File

@@ -15,6 +15,7 @@ import { useIsRuleEditable } from './hooks/useIsRuleEditable';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchAllPromBuildInfoAction, fetchEditableRuleAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks';
import { initialAsyncRequestState } from './utils/redux';
import * as ruleId from './utils/rule-id';
interface ExistingRuleEditorProps {
@@ -22,7 +23,7 @@ interface ExistingRuleEditorProps {
}
const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
useCleanup((state) => state.unifiedAlerting.ruleForm.existingRule);
useCleanup((state) => (state.unifiedAlerting.ruleForm.existingRule = initialAsyncRequestState));
const { loading, result, error, dispatched } = useUnifiedAlertingSelector((state) => state.ruleForm.existingRule);
const dispatch = useDispatch();
const { isEditable } = useIsRuleEditable(ruleId.ruleIdentifierToRuleSourceName(identifier), result?.rule);

View File

@@ -14,6 +14,7 @@ import { globalConfigOptions } from '../../utils/cloud-alertmanager-notifier-typ
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { omitEmptyValues } from '../../utils/receiver-form';
import { initialAsyncRequestState } from '../../utils/redux';
import { OptionField } from './form/fields/OptionField';
@@ -30,7 +31,9 @@ const defaultValues: FormValues = {
export const GlobalConfigForm: FC<Props> = ({ config, alertManagerSourceName }) => {
const dispatch = useDispatch();
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
const readOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
const styles = useStyles2(getStyles);

View File

@@ -12,6 +12,7 @@ import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/ty
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { updateAlertManagerConfigAction } from '../../state/actions';
import { makeAMLink } from '../../utils/misc';
import { initialAsyncRequestState } from '../../utils/redux';
import { ensureDefine } from '../../utils/templates';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
@@ -38,7 +39,7 @@ export const TemplateForm: FC<Props> = ({ existing, alertManagerSourceName, conf
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
const { loading, error } = useUnifiedAlertingSelector((state) => state.saveAMConfig);

View File

@@ -13,6 +13,7 @@ import { useControlledFieldArray } from '../../../hooks/useControlledFieldArray'
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { ChannelValues, CommonSettingsComponentType, ReceiverFormValues } from '../../../types/receiver-form';
import { makeAMLink } from '../../../utils/misc';
import { initialAsyncRequestState } from '../../../utils/redux';
import { ChannelSubForm } from './ChannelSubForm';
import { DeletedSubForm } from './fields/DeletedSubform';
@@ -62,7 +63,7 @@ export function ReceiverForm<R extends ChannelValues>({
defaultValues: JSON.parse(JSON.stringify(defaultValues)),
});
useCleanup((state) => state.unifiedAlerting.saveAMConfig);
useCleanup((state) => (state.unifiedAlerting.saveAMConfig = initialAsyncRequestState));
const { loading } = useUnifiedAlertingSelector((state) => state.saveAMConfig);

View File

@@ -65,7 +65,7 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
const showStep2 = Boolean(type && (type === RuleFormType.grafana || !!dataSourceName));
const submitState = useUnifiedAlertingSelector((state) => state.ruleForm.saveRule) || initialAsyncRequestState;
useCleanup((state) => state.unifiedAlerting.ruleForm.saveRule);
useCleanup((state) => (state.unifiedAlerting.ruleForm.saveRule = initialAsyncRequestState));
const submit = (values: RuleFormValues, exitOnSave: boolean) => {
dispatch(

View File

@@ -50,7 +50,7 @@ export function EditCloudGroupModal(props: ModalProps): React.ReactElement {
}
}, [dispatched, loading, onClose, error]);
useCleanup((state) => state.unifiedAlerting.updateLotexNamespaceAndGroup);
useCleanup((state) => (state.unifiedAlerting.updateLotexNamespaceAndGroup = initialAsyncRequestState));
const onSubmit = (values: FormValues) => {
dispatch(

View File

@@ -26,6 +26,7 @@ import { SilenceFormFields } from '../../types/silence-form';
import { matcherToMatcherField, matcherFieldToMatcher } from '../../utils/alertmanager';
import { parseQueryParamMatchers } from '../../utils/matchers';
import { makeAMLink } from '../../utils/misc';
import { initialAsyncRequestState } from '../../utils/redux';
import { MatchedSilencedRules } from './MatchedSilencedRules';
import MatchersField from './MatchersField';
@@ -106,7 +107,7 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence);
useCleanup((state) => state.unifiedAlerting.updateSilence);
useCleanup((state) => (state.unifiedAlerting.updateSilence = initialAsyncRequestState));
const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI;

View File

@@ -2,13 +2,12 @@ import { cx } from '@emotion/css';
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { locationUtil, NavModel, NavModelItem, TimeRange } from '@grafana/data';
import { locationUtil, NavModel, NavModelItem, TimeRange, PageLayoutType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { Themeable2, withTheme2 } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { Page } from 'app/core/components/Page/Page';
import { PageLayoutType } from 'app/core/components/Page/types';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getKioskMode } from 'app/core/navigation/kiosk';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@@ -353,7 +352,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
<Page
navModel={sectionNav}
pageNav={pageNav}
layout={PageLayoutType.Dashboard}
layout={PageLayoutType.Canvas}
toolbar={toolbar}
className={cx(viewPanel && 'panel-in-fullscreen', queryParams.editview && 'dashboard-content--hidden')}
scrollRef={this.setScrollRef}

View File

@@ -34,7 +34,7 @@ export const useInitDataSourceSettings = (uid: string) => {
return function cleanUp() {
dispatch(
cleanUpAction({
stateSelector: (state) => state.dataSourceSettings,
cleanupAction: (state) => state.dataSourceSettings,
})
);
};

View File

@@ -28,6 +28,7 @@ import { cleanUpAction } from '../../core/actions/cleanUp';
import { ImportDashboardOverview } from './components/ImportDashboardOverview';
import { fetchGcomDashboard, importDashboardJson } from './state/actions';
import { initialImportDashboardState } from './state/reducers';
import { validateDashboardJson, validateGcomDashboard } from './utils/validation';
type DashboardImportPageRouteSearchParams = {
@@ -63,7 +64,7 @@ class UnthemedDashboardImport extends PureComponent<Props> {
}
componentWillUnmount() {
this.props.cleanUpAction({ stateSelector: (state: StoreState) => state.importDashboard });
this.props.cleanUpAction({ cleanupAction: (state) => (state.importDashboard = initialImportDashboardState) });
}
onFileUpload = (event: FormEvent<HTMLInputElement>) => {

View File

@@ -1,5 +1,6 @@
import { act, render, screen } from '@testing-library/react';
import React, { Component } from 'react';
import { Provider } from 'react-redux';
import { Route, Router } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
@@ -8,6 +9,7 @@ import { locationService, setEchoSrv } from '@grafana/runtime';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { GrafanaRoute } from 'app/core/navigation/GrafanaRoute';
import { Echo } from 'app/core/services/echo/Echo';
import { configureStore } from 'app/store/configureStore';
import { getMockPlugin } from '../__mocks__/pluginMocks';
import { getPluginSettings } from '../pluginSettings';
@@ -63,14 +65,17 @@ class RootComponent extends Component<AppRootProps> {
}
function renderUnderRouter() {
const store = configureStore();
const route = { component: AppRootPage };
locationService.push('/a/my-awesome-plugin');
render(
<Router history={locationService.getHistory()}>
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<Route path="/a/:pluginId" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</GrafanaContext.Provider>
<Provider store={store}>
<GrafanaContext.Provider value={getGrafanaContextMock()}>
<Route path="/a/:pluginId" exact render={(props) => <GrafanaRoute {...props} route={route as any} />} />
</GrafanaContext.Provider>
</Provider>
</Router>
);
}

View File

@@ -1,16 +1,25 @@
// Libraries
import React, { Component } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal, HtmlPortalNode } from 'react-reverse-portal';
import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
import { createSelector } from 'reselect';
import { AppEvents, AppPlugin, AppPluginMeta, KeyValue, NavModel, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getNotFoundNav, getWarningNav, getExceptionNav } from 'app/angular/services/nav_model_srv';
import { Page } from 'app/core/components/Page/Page';
import { PageProps } from 'app/core/components/Page/types';
import PageLoader from 'app/core/components/PageLoader/PageLoader';
import { appEvents } from 'app/core/core';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { StoreState, useSelector } from 'app/types';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
import { buildPluginSectionNav } from '../utils';
import { buildPluginPageContext, PluginPageContext } from './PluginPageContext';
interface RouteParams {
pluginId: string;
}
@@ -19,9 +28,135 @@ interface Props extends GrafanaRouteComponentProps<RouteParams> {}
interface State {
loading: boolean;
portalNode: HtmlPortalNode;
plugin?: AppPlugin | null;
nav?: NavModel;
pluginNav: NavModel | null;
}
const initialState: State = { loading: true, pluginNav: null, plugin: null };
export function AppRootPage({ match, queryParams, location }: Props) {
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
const portalNode = useMemo(() => createHtmlPortalNode(), []);
const { plugin, loading, pluginNav } = state;
const sectionNav = useSelector(
createSelector(getNavIndex, (navIndex) => buildPluginSectionNav(location, pluginNav, navIndex))
);
const context = useMemo(() => buildPluginPageContext(sectionNav), [sectionNav]);
useEffect(() => {
loadAppPlugin(match.params.pluginId, dispatch);
}, [match.params.pluginId]);
const onNavChanged = useCallback(
(newPluginNav: NavModel) => dispatch(stateSlice.actions.changeNav(newPluginNav)),
[]
);
if (!plugin || match.params.pluginId !== plugin.meta.id) {
return <Page {...getLoadingPageProps()}>{loading && <PageLoader />}</Page>;
}
if (!plugin.root) {
return (
<Page navModel={getWarningNav('Plugin load error')}>
<div>No root app page component found</div>;
</Page>
);
}
const pluginRoot = plugin.root && (
<plugin.root
meta={plugin.meta}
basename={match.url}
onNavChanged={onNavChanged}
query={queryParams as KeyValue}
path={location.pathname}
/>
);
if (config.featureToggles.topnav && !pluginNav) {
return <PluginPageContext.Provider value={context}>{pluginRoot}</PluginPageContext.Provider>;
}
return (
<>
<InPortal node={portalNode}>{pluginRoot}</InPortal>
{sectionNav ? (
<Page navModel={sectionNav} pageNav={pluginNav?.node}>
<Page.Contents isLoading={loading}>
<OutPortal node={portalNode} />
</Page.Contents>
</Page>
) : (
<Page>
<OutPortal node={portalNode} />
</Page>
)}
</>
);
}
const stateSlice = createSlice({
name: 'prom-builder-container',
initialState: initialState,
reducers: {
setState: (state, action: PayloadAction<Partial<State>>) => {
Object.assign(state, action.payload);
},
changeNav: (state, action: PayloadAction<NavModel>) => {
let pluginNav = action.payload;
// This is to hide the double breadcrumbs the old nav model can cause
if (pluginNav && pluginNav.node.children) {
pluginNav = {
...pluginNav,
node: {
...pluginNav.main,
hideFromBreadcrumbs: true,
},
};
}
state.pluginNav = pluginNav;
},
},
});
function getLoadingPageProps(): Partial<PageProps> {
if (config.featureToggles.topnav) {
return { navId: 'apps' };
}
const loading = { text: 'Loading plugin' };
return {
navModel: { main: loading, node: loading },
};
}
async function loadAppPlugin(pluginId: string, dispatch: React.Dispatch<AnyAction>) {
try {
const app = await getPluginSettings(pluginId).then((info) => {
const error = getAppPluginPageError(info);
if (error) {
appEvents.emit(AppEvents.alertError, [error]);
dispatch(stateSlice.actions.setState({ pluginNav: getWarningNav(error) }));
return null;
}
return importAppPlugin(info);
});
dispatch(stateSlice.actions.setState({ plugin: app, loading: false, pluginNav: null }));
} catch (err) {
dispatch(
stateSlice.actions.setState({
plugin: null,
loading: false,
pluginNav: process.env.NODE_ENV === 'development' ? getExceptionNav(err) : getNotFoundNav(),
})
);
}
}
function getNavIndex(store: StoreState) {
return store.navIndex;
}
export function getAppPluginPageError(meta: AppPluginMeta) {
@@ -37,100 +172,4 @@ export function getAppPluginPageError(meta: AppPluginMeta) {
return null;
}
class AppRootPage extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
portalNode: createHtmlPortalNode(),
};
}
shouldComponentUpdate(nextProps: Props) {
return nextProps.location.pathname.startsWith('/a/');
}
async loadPluginSettings() {
const { params } = this.props.match;
try {
const app = await getPluginSettings(params.pluginId).then((info) => {
const error = getAppPluginPageError(info);
if (error) {
appEvents.emit(AppEvents.alertError, [error]);
this.setState({ nav: getWarningNav(error) });
return null;
}
return importAppPlugin(info);
});
this.setState({ plugin: app, loading: false, nav: undefined });
} catch (err) {
this.setState({
plugin: null,
loading: false,
nav: process.env.NODE_ENV === 'development' ? getExceptionNav(err) : getNotFoundNav(),
});
}
}
componentDidMount() {
this.loadPluginSettings();
}
componentDidUpdate(prevProps: Props) {
const { params } = this.props.match;
if (prevProps.match.params.pluginId !== params.pluginId) {
this.setState({ loading: true, plugin: null });
this.loadPluginSettings();
}
}
onNavChanged = (nav: NavModel) => {
this.setState({ nav });
};
render() {
const { loading, plugin, nav, portalNode } = this.state;
if (!plugin || this.props.match.params.pluginId !== plugin.meta.id) {
return (
<Page>
<PageLoader />
</Page>
);
}
if (!plugin.root) {
// TODO? redirect to plugin page?
return <div>No Root App</div>;
}
return (
<>
<InPortal node={portalNode}>
<plugin.root
meta={plugin.meta}
basename={this.props.match.url}
onNavChanged={this.onNavChanged}
query={this.props.queryParams as KeyValue}
path={this.props.location.pathname}
/>
</InPortal>
{nav ? (
<Page navModel={nav}>
<Page.Contents isLoading={loading}>
<OutPortal node={portalNode} />
</Page.Contents>
</Page>
) : (
<Page>
<OutPortal node={portalNode} />
{loading && <PageLoader />}
</Page>
)}
</>
);
}
}
export default AppRootPage;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { NavModel } from '@grafana/data';
export interface PluginPageContextType {
sectionNav: NavModel;
}
export const PluginPageContext = React.createContext(getInitialPluginPageContext());
PluginPageContext.displayName = 'PluginPageContext';
function getInitialPluginPageContext(): PluginPageContextType {
return {
sectionNav: {
main: { text: 'Plugin page' },
node: { text: 'Plugin page' },
},
};
}
export function buildPluginPageContext(sectionNav: NavModel | null): PluginPageContextType {
return {
sectionNav: sectionNav ?? getInitialPluginPageContext().sectionNav,
};
}

View File

@@ -0,0 +1,52 @@
import { Location as HistoryLocation } from 'history';
import { config } from '@grafana/runtime';
import { buildPluginSectionNav } from './utils';
describe('buildPluginSectionNav', () => {
const pluginNav = { main: { text: 'Plugin nav' }, node: { text: 'Plugin nav' } };
const appsSection = {
text: 'apps',
id: 'apps',
children: [
{
text: 'App1',
children: [
{
text: 'page1',
url: '/a/plugin1/page1',
},
{
text: 'page2',
url: '/a/plugin1/page2',
},
],
},
],
};
const navIndex = { apps: appsSection };
it('Should return pluginNav if topnav is disabled', () => {
config.featureToggles.topnav = false;
const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, {});
expect(result).toBe(pluginNav);
});
it('Should return return section nav if topnav is enabled', () => {
config.featureToggles.topnav = true;
const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex);
expect(result?.main.text).toBe('apps');
});
it('Should set active page', () => {
config.featureToggles.topnav = true;
const result = buildPluginSectionNav(
{ pathname: '/a/plugin1/page2', search: '' } as HistoryLocation,
null,
navIndex
);
expect(result?.main.children![0].children![1].active).toBe(true);
expect(result?.node.text).toBe('page2');
});
});

View File

@@ -1,4 +1,8 @@
import { GrafanaPlugin, PanelPluginMeta, PluginType } from '@grafana/data';
import { Location as HistoryLocation } from 'history';
import { GrafanaPlugin, NavIndex, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getNavModel } from 'app/core/selectors/navModel';
import { importPanelPluginFromMeta } from './importPanelPlugin';
import { getPluginSettings } from './pluginSettings';
@@ -28,3 +32,39 @@ export async function loadPlugin(pluginId: string): Promise<GrafanaPlugin> {
return result;
}
export function buildPluginSectionNav(location: HistoryLocation, pluginNav: NavModel | null, navIndex: NavIndex) {
// When topnav is disabled we only just show pluginNav like before
if (!config.featureToggles.topnav) {
return pluginNav;
}
const originalSection = getNavModel(navIndex, 'apps').main;
const section = { ...originalSection };
// If we have plugin nav don't set active page in section as it will cause double breadcrumbs
const currentUrl = config.appSubUrl + location.pathname + location.search;
let activePage: NavModelItem | undefined;
// Set active page
section.children = (section?.children ?? []).map((child) => {
if (child.children) {
return {
...child,
children: child.children.map((pluginPage) => {
if (currentUrl.startsWith(pluginPage.url ?? '')) {
activePage = {
...pluginPage,
active: true,
};
return activePage;
}
return pluginPage;
}),
};
}
return child;
});
return { main: section, node: activePage ?? section };
}

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { PageLayoutType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { PageToolbar, ToolbarButton } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { Page } from 'app/core/components/Page/Page';
import { PageLayoutType } from 'app/core/components/Page/types';
import { SceneObjectBase } from '../core/SceneObjectBase';
import { SceneComponentProps, SceneObjectStatePlain, SceneObject } from '../core/types';
@@ -54,7 +54,7 @@ function SceneRenderer({ model }: SceneComponentProps<Scene>) {
);
return (
<Page navId="scenes" layout={PageLayoutType.Dashboard} toolbar={pageToolbar}>
<Page navId="scenes" layout={PageLayoutType.Canvas} toolbar={pageToolbar}>
<div style={{ flexGrow: 1, display: 'flex', gap: '8px', overflow: 'auto' }}>
<layout.Component model={layout} isEditing={isEditing} />
{$editor && <$editor.Component model={$editor} isEditing={isEditing} />}

View File

@@ -12,7 +12,7 @@ import { AccessControlAction, Role, StoreState, Team } from 'app/types';
import { connectWithCleanUp } from '../../core/components/connectWithCleanUp';
import { deleteTeam, loadTeams } from './state/actions';
import { setSearchQuery, setTeamsSearchPage } from './state/reducers';
import { initialTeamsState, setSearchQuery, setTeamsSearchPage } from './state/reducers';
import { getSearchQuery, getTeams, getTeamsCount, getTeamsSearchPage, isPermissionTeamAdmin } from './state/selectors';
const pageLimit = 30;
@@ -241,4 +241,8 @@ const mapDispatchToProps = {
setTeamsSearchPage,
};
export default connectWithCleanUp(mapStateToProps, mapDispatchToProps, (state) => state.teams)(TeamList);
export default connectWithCleanUp(
mapStateToProps,
mapDispatchToProps,
(state) => (state.teams = initialTeamsState)
)(TeamList);