Skip to content

Commit

Permalink
Split between global and namespaced repositories (#1959)
Browse files Browse the repository at this point in the history
* Split between global and namespaced repositories

* Apply review
  • Loading branch information
Andres Martinez Gotor authored Aug 25, 2020
1 parent 406869c commit b7eb259
Show file tree
Hide file tree
Showing 9 changed files with 360 additions and 62 deletions.
33 changes: 33 additions & 0 deletions dashboard/src/actions/repos.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,39 @@ describe("fetchRepos", () => {
await store.dispatch(repoActions.fetchRepos(namespace));
expect(store.getActions()).toEqual(expectedActions);
});

it("fetches repos from several namespaces and joins them", async () => {
AppRepository.list = jest
.fn()
.mockImplementationOnce(() => {
return { items: [{ name: "repo1" }] };
})
.mockImplementationOnce(() => {
return { items: [{ name: "repo2" }] };
});

const expectedActions = [
{
type: getType(repoActions.requestRepos),
payload: namespace,
},
{
type: getType(repoActions.requestRepos),
payload: "other-ns",
},
{
type: getType(repoActions.receiveReposSecrets),
payload: [],
},
{
type: getType(repoActions.receiveRepos),
payload: [{ name: "repo1" }, { name: "repo2" }],
},
];

await store.dispatch(repoActions.fetchRepos(namespace, "other-ns"));
expect(store.getActions()).toEqual(expectedActions);
});
});

describe("installRepo", () => {
Expand Down
19 changes: 18 additions & 1 deletion dashboard/src/actions/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const requestRepos = createAction("REQUEST_REPOS", resolve => {
export const receiveRepos = createAction("RECEIVE_REPOS", resolve => {
return (repos: IAppRepository[]) => resolve(repos);
});
export const concatRepos = createAction("RECEIVE_REPOS", resolve => {
return (repos: IAppRepository[]) => resolve(repos);
});

export const receiveReposSecrets = createAction("RECEIVE_REPOS_SECRETS", resolve => {
return (secrets: ISecret[]) => resolve(secrets);
});
Expand Down Expand Up @@ -181,13 +185,26 @@ export const fetchRepoSecret = (
// fetchRepos fetches the AppRepositories in a specified namespace.
export const fetchRepos = (
namespace: string,
...otherNamespaces: string[]
): ThunkAction<Promise<void>, IStoreState, null, AppReposAction> => {
return async (dispatch, getState) => {
try {
dispatch(requestRepos(namespace));
const repos = await AppRepository.list(namespace);
dispatch(receiveRepos(repos.items));
dispatch(fetchRepoSecrets(namespace));
if (!otherNamespaces || !otherNamespaces.length) {
dispatch(receiveRepos(repos.items));
} else {
let totalRepos = repos.items;
await Promise.all(
otherNamespaces.map(async otherNamespace => {
dispatch(requestRepos(otherNamespace));
const otherRepos = await AppRepository.list(otherNamespace);
totalRepos = totalRepos.concat(otherRepos.items);
}),
);
dispatch(receiveRepos(totalRepos));
}
} catch (e) {
dispatch(errorRepos(e, "fetch"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ const defaultProps = {
} as IAppRepository,
};

it("deletes the repo", () => {
it("deletes the repo and refreshes list", async () => {
const deleteRepo = jest.fn();
const fetchRepos = jest.fn();
actions.repos = {
...actions.repos,
deleteRepo,
fetchRepos,
};
const wrapper = mountWrapper(defaultStore, <AppRepoControl {...defaultProps} />);
const deleteButton = wrapper.find(CdsButton).filterWhere(b => b.text() === "Delete");
Expand All @@ -58,10 +60,51 @@ it("deletes the repo", () => {
.find(Modal)
.find(CdsButton)
.filterWhere(b => b.text() === "Delete");
await act(async () => {
await (confirmButton.prop("onClick") as any)();
});
expect(deleteRepo).toHaveBeenCalled();
expect(fetchRepos).toHaveBeenCalledWith(defaultProps.kubeappsNamespace);
});

it("deletes the repo and refreshes list (in other namespace)", async () => {
const deleteRepo = jest.fn();
const fetchRepos = jest.fn();
actions.repos = {
...actions.repos,
deleteRepo,
fetchRepos,
};
const wrapper = mountWrapper(
defaultStore,
<AppRepoControl
{...defaultProps}
repo={
{
metadata: {
name: "bitnami",
namespace: "other",
},
} as IAppRepository
}
/>,
);
const deleteButton = wrapper.find(CdsButton).filterWhere(b => b.text() === "Delete");
act(() => {
(confirmButton.prop("onClick") as any)();
(deleteButton.prop("onClick") as any)();
});
wrapper.update();

const confirmButton = wrapper
.find(ConfirmDialog)
.find(Modal)
.find(CdsButton)
.filterWhere(b => b.text() === "Delete");
await act(async () => {
await (confirmButton.prop("onClick") as any)();
});
expect(deleteRepo).toHaveBeenCalled();
expect(fetchRepos).toHaveBeenCalledWith("other", defaultProps.kubeappsNamespace);
});

it("refreshes the repo", () => {
Expand Down
20 changes: 15 additions & 5 deletions dashboard/src/components/Config/AppRepoList/AppRepoControl.v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import React, { useState } from "react";

import { CdsButton } from "components/Clarity/clarity";
import { useDispatch } from "react-redux";
import { IAppRepository, ISecret } from "shared/types";
import { Action } from "redux";
import { ThunkDispatch } from "redux-thunk";
import { IAppRepository, ISecret, IStoreState } from "shared/types";
import actions from "../../../actions";
import ConfirmDialog from "../../ConfirmDialog/ConfirmDialog.v2";
import { AppRepoAddButton } from "./AppRepoButton.v2";

import "./AppRepoControl.css";

interface IAppRepoListItemProps {
Expand All @@ -26,11 +27,20 @@ export function AppRepoControl({
const [refreshing, setRefreshing] = useState(false);
const openModal = () => setModalOpen(true);
const closeModal = () => setModalOpen(false);
const dispatch = useDispatch();
const dispatch: ThunkDispatch<IStoreState, null, Action> = useDispatch();

const handleDeleteClick = (repoName: string, repoNamespace: string) => {
return () => {
dispatch(actions.repos.deleteRepo(repoName, repoNamespace));
return async () => {
await dispatch(actions.repos.deleteRepo(repoName, repoNamespace));
if (repoNamespace !== kubeappsNamespace) {
// Re-fetch repos in both namespaces because otherwise, the state
// will be updated only with the repos of repoNamespace and removing
// the global ones.
// TODO(andresmgot): This can be refactored once hex UI is dropped
dispatch(actions.repos.fetchRepos(repoNamespace, kubeappsNamespace));
} else {
dispatch(actions.repos.fetchRepos(repoNamespace));
}
closeModal();
};
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from "react";

import { CdsButton } from "components/Clarity/clarity";

import "./AppRepoControl.css";

export function AppRepoDisabledControl() {
return (
<div className="apprepo-control-buttons">
<CdsButton disabled={true} action="outline">
Edit
</CdsButton>
<CdsButton disabled={true} action="outline">
Refresh
</CdsButton>
<CdsButton disabled={true} action="outline">
Delete
</CdsButton>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@
display: inline;
}
}

.page-content {
margin: 0 1.2rem 0 1.2rem;
}
123 changes: 120 additions & 3 deletions dashboard/src/components/Config/AppRepoList/AppRepoList.v2.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import actions from "actions";
import Alert from "components/js/Alert";
import Table from "components/js/Table";
import PageHeader from "components/PageHeader/PageHeader.v2";
import * as React from "react";
import * as ReactRedux from "react-redux";
import { Link } from "react-router-dom";
import { defaultStore, getStore, mountWrapper } from "shared/specs/mountWrapper";
import { app } from "shared/url";
import { AppRepoAddButton } from "./AppRepoButton.v2";
import { AppRepoControl } from "./AppRepoControl.v2";
import { AppRepoDisabledControl } from "./AppRepoDisabledControl.v2";
import AppRepoList from "./AppRepoList.v2";
import { AppRepoRefreshAllButton } from "./AppRepoRefreshAllButton.v2";

Expand Down Expand Up @@ -35,10 +40,21 @@ afterEach(() => {

it("fetches repos and imagePullSecrets", () => {
mountWrapper(defaultStore, <AppRepoList {...defaultProps} />);
expect(actions.repos.fetchRepos).toHaveBeenCalledWith(defaultProps.namespace);
expect(actions.repos.fetchRepos).toHaveBeenCalledWith(
defaultProps.namespace,
defaultProps.kubeappsNamespace,
);
expect(actions.repos.fetchImagePullSecrets).toHaveBeenCalledWith(defaultProps.namespace);
});

it("fetches repos only from the kubeappsNamespace", () => {
mountWrapper(
defaultStore,
<AppRepoList {...defaultProps} namespace={defaultProps.kubeappsNamespace} />,
);
expect(actions.repos.fetchRepos).toHaveBeenCalledWith(defaultProps.kubeappsNamespace);
});

// TODO: Remove this test when app repos are supported in different clusters
it("shows a warning if the cluster is not the default one", () => {
const wrapper = mountWrapper(defaultStore, <AppRepoList {...defaultProps} cluster="other" />);
Expand Down Expand Up @@ -69,5 +85,106 @@ it("shows an error deleting a repo", () => {
expect(wrapper.find(Alert)).toIncludeText("boom!");
});

// TODO(andresmgot): Add test for the tables once global/namespaced repositories
// are implemented.
describe("global and namespaced repositories", () => {
const globalRepo = {
metadata: {
name: "bitnami",
namespace: defaultProps.kubeappsNamespace,
},
spec: {},
};

const namespacedRepo = {
metadata: {
name: "my-repo",
namespace: defaultProps.namespace,
},
spec: {},
};

it("shows a message if no global or namespaced repos exist", () => {
const wrapper = mountWrapper(defaultStore, <AppRepoList {...defaultProps} />);
expect(
wrapper.find("p").filterWhere(p => p.text().includes("No global repositories found")),
).toExist();
expect(
wrapper
.find("p")
.filterWhere(p => p.text().includes("The current namespace doesn't have any repositories")),
).toExist();
});

it("shows the global repositories with the buttons disabled if the current namespace is other", () => {
const wrapper = mountWrapper(
getStore({
repos: {
repos: [globalRepo],
},
}),
<AppRepoList {...defaultProps} namespace="other" />,
);

// A link to manage the repos should exist
expect(wrapper.find(Link).prop("to")).toBe(
app.config.apprepositories("default", defaultProps.kubeappsNamespace),
);
expect(wrapper.find(Table)).toHaveLength(1);
// The control buttons should be disabled
expect(wrapper.find(AppRepoDisabledControl)).toExist();
expect(wrapper.find(AppRepoControl)).not.toExist();
// The content related to namespaced repositories should exist
expect(
wrapper.find("h3").filterWhere(h => h.text().includes("Namespace Repositories")),
).toExist();
});

it("shows the global repositories with the buttons enabled", () => {
const wrapper = mountWrapper(
getStore({
repos: {
repos: [globalRepo],
},
}),
<AppRepoList {...defaultProps} namespace={defaultProps.kubeappsNamespace} />,
);

// A link to manage the repos should not exist since we are already there
expect(wrapper.find(Link)).not.toExist();
expect(wrapper.find(Table)).toHaveLength(1);
// The control buttons should be enabled
expect(wrapper.find(AppRepoDisabledControl)).not.toExist();
expect(wrapper.find(AppRepoControl)).toExist();
// The content related to namespaced repositories should be hidden
expect(
wrapper.find("h3").filterWhere(h => h.text().includes("Namespace Repositories")),
).not.toExist();
});

it("shows global and namespaced repositories", () => {
const wrapper = mountWrapper(
getStore({
repos: {
repos: [globalRepo, namespacedRepo],
},
}),
<AppRepoList {...defaultProps} namespace={namespacedRepo.metadata.namespace} />,
);

// A table per repository type
expect(wrapper.find(Table)).toHaveLength(2);
// The control buttons should be enabled for the namespaced repository and disabled
// for the global one
expect(
wrapper
.find(Table)
.at(0)
.find(AppRepoDisabledControl),
).toExist();
expect(
wrapper
.find(Table)
.at(1)
.find(AppRepoControl),
).toExist();
});
});
Loading

0 comments on commit b7eb259

Please sign in to comment.